MRBS 1.12.2 等保2.0二级整改完整提交

包含:登录失败锁定、90天密码有效期、30分钟会话超时、
强制改密、登录审计日志、屏幕水印、企业背景图、
备案信息固定底部、favicon、JS空集合保护、
会话过期体验优化(403 JSON)、display_errors 关闭、
固定 key 根治 Integrity check failed 等全部改动

注意:config.inc.php/.htaccess/.user.ini 含敏感信息,
通过 .gitignore 排除,勿推送到公开仓库。
This commit is contained in:
人事系统开发
2026-09-09 16:55:02 +08:00
commit 1ba6efd8ed
2151 changed files with 528780 additions and 0 deletions
+311
View File
@@ -0,0 +1,311 @@
<?php
declare(strict_types=1);
namespace MRBS\Intl;
use Exception;
/**
* A partial, very basic, emulation of the PHP \Collator class. Some methods are not implemented, and
* some attributes are not supported. This class is only intended as a very basic fallback if the intl
* extension is not available. For best results use the intl extension.
* @see \Collator
*/
class Collator
{
public const DEFAULT_VALUE = -1;
public const PRIMARY = 0;
public const SECONDARY = 1;
public const TERTIARY = 2;
public const DEFAULT_STRENGTH = 2;
public const QUATERNARY = 3;
public const IDENTICAL = 15;
public const OFF = 16;
public const ON = 17;
public const SHIFTED = 20;
public const NON_IGNORABLE = 21;
public const LOWER_FIRST = 24;
public const UPPER_FIRST = 25;
public const FRENCH_COLLATION = 0;
public const ALTERNATE_HANDLING = 1;
public const CASE_FIRST = 2;
public const CASE_LEVEL = 3;
public const NORMALIZATION_MODE = 4;
public const STRENGTH = 5;
public const HIRAGANA_QUATERNARY_MODE = 6;
public const NUMERIC_COLLATION = 7;
public const SORT_REGULAR = 0;
public const SORT_STRING = 1;
public const SORT_NUMERIC = 2;
/**
* Default values for attributes.
* @see \Collator
*/
private const ATTRIBUTES_DEFAULT_VALUES = [
self::FRENCH_COLLATION => self::OFF,
self::ALTERNATE_HANDLING => self::NON_IGNORABLE,
self::CASE_FIRST => self::OFF,
self::CASE_LEVEL => self::OFF,
self::NORMALIZATION_MODE => self::OFF,
self::STRENGTH => self::DEFAULT_STRENGTH,
self::HIRAGANA_QUATERNARY_MODE => self::OFF,
self::NUMERIC_COLLATION => self::OFF
];
/**
* Possible values for attributes.
* @see \Collator
*/
private const ATTRIBUTES_POSSIBLE_VALUES = [
self::FRENCH_COLLATION => [self::ON, self::OFF, self::DEFAULT_VALUE],
self::ALTERNATE_HANDLING => [self::NON_IGNORABLE, self::SHIFTED, self::DEFAULT_VALUE],
self::CASE_FIRST => [self::OFF, self::LOWER_FIRST, self::UPPER_FIRST, self::DEFAULT_VALUE],
self::CASE_LEVEL => [self::OFF, self::ON, self::DEFAULT_VALUE],
self::NORMALIZATION_MODE => [self::OFF, self::ON, self::DEFAULT_VALUE],
self::STRENGTH => [self::PRIMARY, self::SECONDARY, self::TERTIARY, self::QUATERNARY, self::IDENTICAL, self::DEFAULT_STRENGTH],
self::HIRAGANA_QUATERNARY_MODE => [self::OFF, self::ON, self::DEFAULT_VALUE],
self::NUMERIC_COLLATION => [self::OFF, self::ON, self::DEFAULT_VALUE],
];
private $attributes = [];
private $locale;
/**
* @see \Collator::__construct()
*/
public function __construct(string $locale)
{
$this->locale = $locale;
// Set the default values for the attributes
foreach(self::ATTRIBUTES_DEFAULT_VALUES as $attribute => $default_value)
{
$this->setAttribute($attribute, $default_value);
}
}
/**
* @see \Collator::asort()
*/
public function asort(array &$array, int $flags = self::SORT_REGULAR): bool
{
return $this->genericSort(true, $array, $flags);
}
/**
* @see \Collator::sort()
*/
public function sort(array &$array, int $flags = self::SORT_REGULAR): bool
{
return $this->genericSort(false, $array, $flags);
}
/**
* @return int<-1,1>|false
* @see \Collator::compare()
*/
public function compare(string $string1, string $string2)
{
// Primary and secondary strengths are case-insensitive. The sort() method in this class
// cannot perform a locale aware, case-insensitive sort, so make the two strings the same
// case here, before trying the sort.
if (in_array($this->getStrength(), [self::PRIMARY, self::SECONDARY], true))
{
$string1 = mb_strtolower($string1);
$string2 = mb_strtolower($string2);
}
// Trivial case
if ($string1 === $string2)
{
return 0;
}
// Sort the array. If the order is reversed, then $string1 > $string2.
// (When this function is being used as a callback for usort, and if the original array
// is sorted in ascending order - which it well might be if it's the result of
// an SQL query with an ORDER BY - then it's fastest to test for $string1 > $string2
// first, as below.)
$original_array = [$string1, $string2];
$array = $original_array;
$this->sort($array);
if ($array !== $original_array)
{
return 1;
}
// Otherwise, flip the array and try again. If the order is reversed, then $string2 > $string1.
$original_array = [$string2, $string1];
$array = $original_array;
$this->sort($array);
if ($array !== $original_array)
{
return -1;
}
// Otherwise they must be equal
return 0;
}
/**
* @see \Collator::create()
*/
public static function create(string $locale): ?self
{
return new self($locale);
}
/**
* @return int|false
* @see \Collator::getAttribute()
*/
public function getAttribute(int $attribute)
{
if (!array_key_exists($attribute, $this->attributes))
{
return false;
}
return $this->attributes[$attribute];
}
/**
* @return int|false
* @see \Collator::getErrorCode()
*/
public function getErrorCode()
{
throw new Exception("Not yet implemented");
}
/**
* @return string|false
* @see \Collator::getErrorMessage()
*/
public function getErrorMessage()
{
throw new Exception("Not yet implemented");
}
/**
* @return string|false
* @see \Collator::getLocale()
*/
public function getLocale()
{
throw new Exception("Not yet implemented");
}
/**
* @return string|false
* @see \Collator::getSortKey()
*/
public function getSortKey(string $string)
{
throw new Exception("Not yet implemented");
}
/**
* @see \Collator::getStrength()
*/
public function getStrength(): int
{
return $this->getAttribute(self::STRENGTH);
}
/**
* @see \Collator::setAttribute()
*/
public function setAttribute(int $attribute, int $value): bool
{
if (!in_array($value, self::ATTRIBUTES_POSSIBLE_VALUES[$attribute]))
{
return false;
}
// TODO: The manual (https://www.php.net/manual/en/class.collator.php#collator.constants.french-collation)
// TODO: says that FRENCH_COLLATION "is automatically set to On for the French locales and a few others".
// TODO: However, this doesn't seem to be the case in testing: it's always Off. Probably doesn't matter
// TODO: in practice though as this emulator won't be able to do anything about it anyway.
$this->attributes[$attribute] = ($value === self::DEFAULT_VALUE) ? self::ATTRIBUTES_DEFAULT_VALUES[$attribute] : $value;
return true;
}
/**
* @see \Collator::setStrength()
* @return true
*/
public function setStrength(int $strength)
{
$this->setAttribute(self::STRENGTH, $strength);
return true;
}
/**
* @see \Collator::sortWithSortKeys()
*/
public function sortWithSortKeys(array &$array): bool
{
throw new Exception("Not yet implemented");
}
private function genericSort(bool $maintain_index_association, array &$array, int $flags = self::SORT_REGULAR): bool
{
$locale_switcher = new LocaleSwitcher(LC_COLLATE, $this->locale);
$locale_switcher->switch();
// Convert the flags to the equivalent value for the ordinary functions sort()/asort().
// Note that the sort()/asort() flags can be just one of SORT_REGULAR, SORT_NUMERIC, SORT_STRING, SORT_LOCALE_STRING or SORT_NATURAL.
// Then SORT_FLAG_CASE can be combined with SORT_STRING or SORT_NATURAL.
// This means that the ordinary PHP sort() functions do not support locale-aware natural or case-insensitive sorting.
switch ($flags)
{
case self::SORT_STRING:
case self::SORT_REGULAR:
// If NUMERIC_COLLATION is on, then use SORT_NATURAL, otherwise use SORT_LOCALE_STRING
$ordinary_flags = ($this->getAttribute(self::NUMERIC_COLLATION) === self::ON) ? SORT_NATURAL : SORT_LOCALE_STRING;
break;
case self::SORT_NUMERIC:
$ordinary_flags = SORT_NUMERIC;
break;
default:
throw new \InvalidArgumentException("Invalid flags value '$flags'");
break;
}
// Primary and secondary strengths are case-insensitive.
// SORT_FLAG_CASE can only be used with SORT_STRING or SORT_NATURAL.
if (in_array($ordinary_flags, [SORT_STRING, SORT_NATURAL]) && in_array($this->getStrength(), [self::PRIMARY, self::SECONDARY], true))
{
$ordinary_flags |= SORT_FLAG_CASE;
}
if ($maintain_index_association)
{
asort($array, $ordinary_flags);
}
else
{
sort($array, $ordinary_flags);
}
$locale_switcher->restore();
return true;
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Intl;
// An interface for converting ICU datetime patterns to another format, eg strftime
interface Formatter
{
// Convert an ICU pattern token into the nearest equivalent token.
// Throws an exception if the token can't be converted.
public function convert(string $token) : string;
public function escape(string $char) : string;
}
+176
View File
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace MRBS\Intl;
class FormatterFlatpickr implements Formatter
{
// See https://flatpickr.js.org/formatting/
private const FORMATTING_TOKENS = [
'd', 'D', 'l', 'j', 'J', 'w', 'W', 'F', 'm', 'n', 'M',
'U', 'y', 'Y', 'Z', 'H', 'h', 'G', 'i', 'S', 's', 'K'];
// Convert an ICU pattern token into the nearest equivalent token.
// Throws an exception if the token can't be converted.
public function convert(string $token) : string
{
switch ($token) {
// AM or PM
case 'a': // PM [abbrev]
case 'aa': // PM [abbrev]
case 'aaa': // PM [abbrev]
case 'aaaa': // PM [wide]
case 'aaaaa': // p
// am, pm, noon, midnight
case 'b': // mid.
case 'bb': // mid.
case 'bbb': // mid.
case 'bbbb': // midnight
case 'bbbbb': // md
// flexible day periods
case 'B': // at night [abbrev]
case 'BB': // at night [abbrev]
case 'BBB': // at night [abbrev]
case 'BBBB': // at night [wide]
case 'BBBBB': // at night [narrow]
$format = 'K'; // AM/PM, eg AM or PM
break;
// stand-alone local day of week
case 'cccc': // Tuesday
// day of week
case 'EEEE': // Tuesday
// local day of week
case 'eeee': // Tuesday
$format = 'l'; // A full textual representation of the day, eg Sunday through Saturday
break;
// stand-alone local day of week
case 'ccc': // Tue
case 'ccccc': // T
case 'cccccc': // Tu
// day of week
case 'E': // Tue
case 'EE': // Tue
case 'EEE': // Tue
case 'EEEEE': // T
case 'EEEEEE': // Tu
// local day of week
case 'eee': // Tue
case 'eeeee': // T
case 'eeeeee': // Tu
$format = 'D'; // A textual representation of a day, eg Mon through Sun
break;
// day in month
case 'd': // 2
$format = 'j'; // Day of the month without leading zeros, eg1 to 31
break;
// day in month
case 'dd': // 02
$format = 'd'; // Day of the month, 2 digits with leading zeros, eg 01 to 31
break;
// hour in day (0~23)
case 'H': // 0
// hour in day (0~23)
case 'HH': // 00
$format = 'H'; // Hours (24 hours), eg 00 to 23
break;
// hour in am/pm (1~12)
case 'h': // 7
$format = 'h'; // Hours 1 to 12
break;
// hour in am/pm (1~12)
case 'hh': // 07
$format = 'G'; // Hours, 2 digits with leading zeros 1 to 12
break;
// stand-alone month in year
case 'L': // 9
// month in year
case 'M': // 9
$format = 'n'; // Numeric representation of a month, without leading zeros 1 through 12
break;
// stand-alone month in year
case 'LL': // 09
// month in year
case 'MM': // 09
$format = 'm'; // Numeric representation of a month, with leading zero 01 through 12
break;
// stand-alone month in year
case 'LLL': // Sep
// month in year
case 'MMM': // Sep
$format = 'M'; // A short textual representation of a month Jan through Dec
break;
// stand-alone month in year
case 'LLLL': // September
// month in year
case 'MMMM': // September
$format = 'F'; // A full textual representation of a month January through December
break;
// minute in hour
case 'm': // 4
$format = 'i'; // Minutes 00 to 59
break;
// minute in hour
case 'mm': // 04
$format = 'i'; // Minutes 00 to 59
break;
// second in minute
case 's': // 5
$format = 's'; // Seconds 0, 1 to 59
break;
// second in minute
case 'ss': // 05
$format = 'S'; // Seconds, 2 digits 00 to 59
break;
// week of year
// The ICU documentation isn't very clear what is meant by "week of year", but it seems to be locale
// dependent. In many locales it is the ISO week number, but in some locales it isn't. It (partly?)
// depends on the locale's first day of the week, which can be got from IntlCalendar::getFirstDayOfWeek().
case 'w': // 7
case 'ww': // 07
$format = 'W'; // Numeric representation of the week 0 (first week of the year) through 52 (last week of the year)
break;
// year
case 'y': // 1996
case 'yyyy': // 1996
$format = 'Y'; // A full numeric representation of a year, 4 digits, eg 1999 or 2003
break;
// year
case 'yy': // 96
$format = 'y'; // A two digit representation of a year 99 or 03
break;
default:
throw new \MRBS\Exception("Could not convert '$token'");
break;
}
return $format;
}
public function escape(string $char): string
{
if (in_array($char, self::FORMATTING_TOKENS))
{
return '\\\\' . $char;
}
return $char;
}
}
+200
View File
@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace MRBS\Intl;
class FormatterStrftime implements Formatter
{
// Convert an ICU pattern token into the nearest equivalent token.
// Throws an exception if the token can't be converted.
public function convert(string $token) : string
{
switch ($token) {
// AM or PM
case 'a': // PM [abbrev]
case 'aa': // PM [abbrev]
case 'aaa': // PM [abbrev]
case 'aaaa': // PM [wide]
case 'aaaaa': // p
// am, pm, noon, midnight
case 'b': // mid.
case 'bb': // mid.
case 'bbb': // mid.
case 'bbbb': // midnight
case 'bbbbb': // md
// flexible day periods
case 'B': // at night [abbrev]
case 'BB': // at night [abbrev]
case 'BBB': // at night [abbrev]
case 'BBBB': // at night [wide]
case 'BBBBB': // at night [narrow]
$format = '%P'; // lower-case 'am' or 'pm' based on the given time
break;
// stand-alone local day of week
case 'cccc': // Tuesday
// day of week
case 'EEEE': // Tuesday
// local day of week
case 'eeee': // Tuesday
$format = '%A'; // A full textual representation of the day, eg Sunday through Saturday
break;
// stand-alone local day of week
case 'ccc': // Tue
case 'ccccc': // T
case 'cccccc': // Tu
// day of week
case 'E': // Tue
case 'EE': // Tue
case 'EEE': // Tue
case 'EEEEE': // T
case 'EEEEEE': // Tu
// local day of week
case 'eee': // Tue
case 'eeeee': // T
case 'eeeeee': // Tu
$format = '%a'; // An abbreviated textual representation of the day, eg Sun through Sat
break;
// day in month
case 'd': // 2
$format = '%i'; // One/two digit day of the month, eg 1 to 31
break;
// day in month
case 'dd': // 02
$format = '%d'; // Two-digit day of the month (with leading zeros), eg 01 to 31
break;
// day of year
case 'D': // 189
$format = '%E'; // Day of the year without leading zeroes
break;
// hour in day (0~23)
case 'H': // 0
$format = '%k'; // Hour in 24-hour format, with a space preceding single digits, eg 0 through 23
break;
// hour in day (0~23)
case 'HH': // 00
$format = '%H'; // Two digit representation of the hour in 24-hour format, eg 00 through 23
break;
// hour in am/pm (1~12)
case 'h': // 7
$format = '%o'; // Hour in 12-hour format, with no space preceding single digits
break;
// hour in am/pm (1~12)
case 'hh': // 07
$format = '%I'; // Two digit representation of the hour in 12-hour format, eg 01 through 12
break;
// stand-alone month in year
case 'L': // 9
// month in year
case 'M': // 9
$format = '%f'; // One/two digit representation of the month, eg 1 (for January) through 12 (for December)
break;
// stand-alone month in year
case 'LL': // 09
// month in year
case 'MM': // 09
$format = '%m'; // Two digit representation of the month, eg 01 (for January) through 12 (for December)
break;
// stand-alone month in year
case 'LLL': // Sep
// month in year
case 'MMM': // Sep
$format = '%b'; // Abbreviated month name, based on the locale, eg Jan through Dec
break;
// stand-alone month in year
case 'LLLL': // September
// month in year
case 'MMMM': // September
$format = '%B'; // Full month name, based on the locale, eg January through December
break;
// minute in hour
case 'm': // 4
$format = '%q'; // Minute in the hour, with no leading zero
break;
// minute in hour
case 'mm': // 04
$format = '%M'; // Minute in the hour, with leading zeroes
break;
// second in minute
case 's': // 5
$format = '%v'; // Seconds, with no leading zeroes
break;
// second in minute
case 'ss': // 05
$format = '%S'; // Two digit representation of the second, eg 00 through 59
break;
// week of year
// The ICU documentation isn't very clear what is meant by "week of year", but it seems to be locale
// dependent. In many locales it is the ISO week number, but in some locales it isn't. It (partly?)
// depends on the locale's first day of the week, which can be got from IntlCalendar::getFirstDayOfWeek().
case 'w': // 7
$format = '%J'; // ISO-8601:1988 week number of the given year without leading zeroes, eg 1 through 53
break;
case 'ww': // 07
$format = '%V'; // ISO-8601:1988 week number of the given year, eg 01 through 53
break;
// year
case 'y': // 1996
case 'yyyy': // 1996
$format = '%Y'; // Four digit representation for the year, eg 2038
break;
// year
case 'yy': // 96
$format = '%y'; // Two digit representation of the year, eg 09 for 2009, 79 for 1979
break;
// Time Zone: specific non-location
case 'z': // PDT
case 'zz': // PDT
case 'zzz': // PDT
case 'zzzz': // Pacific Daylight Time
$format = '%Z'; // The time zone abbreviation, eg EST for Eastern Time
break; // Windows: The %z and %Z modifiers both return the time zone name instead of the offset or abbreviation
default:
throw new \MRBS\Exception("Could not convert '$token'");
break;
}
return $format;
}
public function escape(string $char): string
{
switch ($char)
{
case "\n":
return '%n';
break;
case "\t":
return '%t';
break;
case "%":
return '%%';
break;
default:
return $char;
break;
}
}
}
+417
View File
@@ -0,0 +1,417 @@
<?php
declare(strict_types=1);
namespace MRBS\Intl;
// A class provides a basic emulation of PHP's IntlDateFormatter class.
//
// The emulation uses the deprecated function strftime() and is only necessary for older
// PHP systems where the Intl extension isn't available. Eventually the emulation can be
// dispensed with.
//
// Note that some servers have out of date versions of the ICU library that can't be updated
// easily. In those cases better results can sometimes be achieved by using strftime() and
// this can be forced by explicitly using this class.
use DateTimeInterface;
use MRBS\Exception;
use MRBS\Language;
use MRBS\System;
// We need to check that the 'intl' extension is loaded because earlier versions of
// MRBS had the IntlDateFormatter emulation class at the top level in lib. If users
// have upgraded by just overwriting files without deleting that file, then it will
// be picked up by the class_exists() test and used instead of the more up-to-date
// emulation below.
// Note that there is a polyfill for IntlDateFormatter available at
// https://github.com/symfony/polyfill-intl-icu, but it is limited to the 'en' locale.
// There are also backwards compatibility versions of strftime() available, but
// IntlDateFormatter is a more powerful solution.
class IntlDateFormatter
{
const FULL = 0;
const LONG = 1;
const MEDIUM = 2;
const SHORT = 3;
const NONE = -1;
const RELATIVE_FULL = 128; // Available as of PHP 8.0.0, for dateType only
const RELATIVE_LONG = 129; // Available as of PHP 8.0.0, for dateType only
const RELATIVE_MEDIUM = 130; // Available as of PHP 8.0.0, for dateType only
const RELATIVE_SHORT = 131; // Available as of PHP 8.0.0, for dateType only
const GREGORIAN = 1;
const TRADITIONAL = 0;
private const TYPE_NAMES = array(
self::FULL => 'full',
self::LONG => 'long',
self::MEDIUM => 'medium',
self::SHORT => 'short',
self::NONE => 'none'
);
private const DEFAULT_LOCALE = 'en';
private $locale;
private $dateType;
private $timeType;
private $timezone;
private $calendar;
private $pattern;
public function __construct(
?string $locale,
int $dateType = self::FULL,
int $timeType = self::FULL,
$timezone = null,
$calendar = null,
?string $pattern = null)
{
if (!function_exists('strftime'))
{
throw new Exception("Neither the IntlDateFormatter class nor the strftime() function exist on this server");
}
// Emulate PHP 8.4 and later by detecting invalid locales now, in order to avoid problems later on.
if (isset($locale) && !System::isAvailableLocale($locale))
{
$message = 'Argument #1 ($locale) "' . $locale . '" is invalid';
$throwable = (version_compare(PHP_VERSION, '8.0') >= 0) ? '\ValueError' : '\Exception';
throw new $throwable($message);
}
$this->locale = $locale;
$this->dateType = $dateType;
$this->timeType = $timeType;
$this->timezone = $timezone ?? date_default_timezone_get();
$this->calendar = $calendar ?? self::GREGORIAN;
if (!isset($pattern)) {
$file = MRBS_ROOT . "/intl/types/" .
self::TYPE_NAMES[$this->dateType] . "_" . self::TYPE_NAMES[$this->timeType] . ".ini";
if (is_readable($file)) {
$patterns = parse_ini_file($file);
if (!empty($patterns)) {
$pattern = $patterns[Language::convertToBcp47($this->locale)] ?? $patterns[self::DEFAULT_LOCALE] ?? null;
}
}
}
if (!isset($pattern)) {
throw new Exception("Could not get pattern");
}
$this->setPattern($pattern);
}
public function format($datetime)
{
// $datetime can be many types
// TODO: Handle the remaining possible types
if ($datetime instanceof DateTimeInterface)
{
$timestamp = $datetime->getTimestamp();
}
else
{
$timestamp = (int)$datetime;
}
$converter = new IntlDatePatternConverter(new FormatterStrftime());
return $this->strftimePlus($converter->convert($this->pattern), $timestamp);
}
//Get the calendar type used for the IntlDateFormatter
public function getCalendar()
{
return $this->calendar ?? false;
}
// Get the datetype used for the IntlDateFormatter
public function getDateType()
{
return $this->dateType ?? false;
}
// Get the locale used by formatter
public function getLocale(int $type=Locale::ACTUAL_LOCALE)
{
switch ($type)
{
// TODO: Do something with $type, though it's not exactly clear what the difference is
// TODO: between the two types. See also https://www.php.net/manual/en/collator.getlocale.php
case LOCALE::ACTUAL_LOCALE:
case LOCALE::VALID_LOCALE:
return $this->locale ?? false;
break;
default:
return false;
break;
}
}
// Get the pattern used for the IntlDateFormatter
public function getPattern()
{
return $this->pattern ?? false;
}
// Get the timetype used for the IntlDateFormatter
public function getTimeType()
{
return $this->timeType ?? false;
}
// The standard PHP version can also return false
public function setPattern(string $pattern): bool
{
$this->pattern = $pattern;
return true;
}
// Format a local time/date according to locale settings, returning the
// result as a UTF-8 string. This function is based on strftime()
// $time can be an int or a float (union type declarations not supported until PHP 8.0)
// $locale can either be a string or an array of locales. If $locale
// is not set then the current locale is used.
//
// This method extends the standard PHP strftime() function and adds extra formats:
//
// %f Numeric representation of the month 1 (for January) through 12 (for December)
// without leading zeroes. Won't
// necessarily work in locales that don't
// use [0..9] for the month.
//
// %i One/two digit day of the month, with no 1 to 31
// leading space
//
// %o Hour in 12-hour format, with no space 1 through 12
// preceding single digits
//
// %q Minute in the hour, with no leading zero 4
//
// %v Seconds, with no leading zero
//
// %E Day of year, with no leading zeroes
private function strftimePlus(string $format, int $timestamp): string
{
$server_os = System::getServerOS();
// Set the temporary locale. Note that $this->locale could be an array of locales,
// so we need to find out which locale actually worked.
if (!empty($this->locale)) {
$old_locale = setlocale(LC_TIME, '0');
if (false === ($new_locale = Language::setLocale(LC_TIME, $this->locale)))
{
$new_locale = $old_locale;
$locale = is_array($this->locale) ? json_encode($this->locale) : "'$this->locale'";
$message = "Could not set locale to $locale; continuing to use '$old_locale'";
trigger_error($message, E_USER_WARNING);
}
}
elseif ($server_os == "windows") {
// If we are running Windows we have to set the locale again in case another script
// running in the same process has changed the locale since we first set it. See the
// warning on the PHP manual page for setlocale():
//
// "The locale information is maintained per process, not per thread. If you are
// running PHP on a multithreaded server API like IIS or Apache on Windows, you may
// experience sudden changes in locale settings while a script is running, though
// the script itself never called setlocale(). This happens due to other scripts
// running in different threads of the same process at the same time, changing the
// process-wide locale using setlocale()."
$new_locale = Language::getInstance()->getWebLocale();
Language::setLocale(LC_ALL, $new_locale);
}
else {
$new_locale = null;
}
$result = self::doStrftimePlus($format, $timestamp, $new_locale);
// Restore the original locale
if (!empty($this->locale)) {
setlocale(LC_TIME, $old_locale);
}
return $result;
}
/**
* Wrapper for strftime() that suppresses deprecation errors
*
* @return false|string
*/
private static function doStrftime(string $format, ?int $timestamp = null)
{
assert(version_compare(MRBS_MIN_PHP_VERSION, '8.0.0', '<'), "The line below can be removed.");
$timestamp = $timestamp ?? time(); // $timestamp only became nullable in strftime() in PHP 8.0.0
// Temporarily suppress deprecation errors so that we are not flooded with them.
// We have a single message in init.inc.
$error_level = error_reporting();
error_reporting($error_level & ~E_DEPRECATED);
$result = strftime($format, $timestamp);
error_reporting($error_level);
return $result;
}
/**
* Test whether a specifier is supported by strftime() and replace it with an alternative if not.
*/
private static function testAndReplaceFormat(string $specifier, string $replacement, string $format) : string
{
if (false === self::doStrftime($specifier))
{
return str_replace($specifier, $replacement, $format);
}
return $format;
}
private static function doStrftimePlus(string $format, int $timestamp, ?string $locale): string
{
// Test whether certain specifiers are supported on this OS for this locale. We do an actual test,
// rather than just checking which OS we are running on, because it is more reliable.
$doubtful_specifiers = [
'%R' => '%H:%M', // Not supported on Windows
'%P' => '%p', // Not supported on Windows, macOS and also some locales
'%l' => '%I', // Not supported on Windows
'%e' => '%#d' // Not supported on Windows
];
foreach ($doubtful_specifiers as $specifier => $replacement)
{
$format = self::testAndReplaceFormat($specifier, $replacement, $format);
}
// %p doesn't actually work in some locales, so we have to patch it up ourselves by using
// date() instead of strftime().
// Note that we may be using %p instead of %P, because %P isn't supported for this locale. If that's
// the case we're going to get a lowercase result, instead of uppercase as intended. But that
// probably doesn't matter as that locale would almost certainly be using a 24-hour format anyway,
// which is why %P isn't supported in the first place.
if (preg_match('/%p/', $format) && (false === self::doStrftime('%p', $timestamp)))
{
$format = preg_replace('/%p/', date('a', $timestamp), $format);
}
$result = '';
// Split the format into individual tokens so that we can process our extensions
$tokens = self::parseStrftimeFormat($format);
foreach ($tokens as $token) {
if (mb_strlen($token) === 1) {
$result .= $token;
}
else {
switch ($token) {
case '%E':
// We want the day of the year without leading zeroes.
$formatted = self::doStrftimePlus('%j', $timestamp, $locale);
$formatted = ltrim($formatted, '0');
break;
case '%f':
// We want a month number without leading zeroes. We can't use date('n', $time)
// because date will return an English answer with a month made up of the characters
// [0..9] which won't be correct for all locales.
$formatted = self::doStrftimePlus('%m', $timestamp, $locale);
$formatted = ($formatted === '00') ? '0' : ltrim($formatted, '0');
break;
case '%i':
$formatted = ltrim(self::doStrftimePlus('%e', $timestamp, $locale));
break;
case '%J':
// We want the week of the year without leading zeroes.
$formatted = self::doStrftimePlus('%V', $timestamp, $locale);
$formatted = ltrim($formatted, '0');
break;
case '%o':
$formatted = ltrim(self::doStrftimePlus('%l', $timestamp, $locale));
break;
case '%q':
// We want a minute without leading zeroes.
$formatted = self::doStrftimePlus('%M', $timestamp, $locale);
$formatted = ($formatted === '00') ? '0' : ltrim($formatted, '0');
break;
case '%v':
// We want seconds without leading zeroes.
$formatted = self::doStrftimePlus('%S', $timestamp, $locale);
$formatted = ($formatted === '00') ? '0' : ltrim($formatted, '0');
break;
default:
$formatted = self::doStrftime($token, $timestamp);
break;
}
$result .= System::utf8ConvertFromLocale($formatted, $locale);
}
}
return $result;
}
// Parses a strftime format into an array of strings, which will either be two or three-character
// formats or one-character text strings.
private static function parseStrftimeFormat(string $format): array
{
$result = array();
// Split the format into an array of multibyte characters
$chars = preg_split("//u", $format, 0, PREG_SPLIT_NO_EMPTY);
while (null !== ($char = array_shift($chars))) {
if ($char !== '%') {
// It's ordinary text
$result[] = $char;
}
else {
// Get the next character which will either be a conversion specifier or an escaped character
$char = array_shift($chars);
switch ($char) {
case null:
throw new Exception("Invalid format '$format'");
break;
case 'n':
$result[] = "\n";
break;
case 't':
$result[] = "\t";
break;
case '%':
$result[] = "%";
break;
case '#':
// This covers the case of '%#d' on Windows
$char = array_shift($chars);
if (!isset($char)) {
throw new Exception("Invalid format '$format'");
}
else {
$result [] = "%#$char";
}
break;
default:
$result [] = "%$char";
break;
}
}
}
return $result;
}
}
@@ -0,0 +1,35 @@
<?php
// Note that we cannot use strict types here as some early versions of PHP (eg 7.2.34) throw
// a TypeError if null is passed as the sixth parameter to \IntlDateFormatter::_construct(),
// despite the signature on the manual page.
// declare(strict_types=1);
namespace MRBS\Intl;
// A factory class for creating either an instance of \IntlDateFormatter or \MRBS\Intl\IntlDateFormatter,
// depending on the setting of the $force_strftime config variable.
class IntlDateFormatterFactory
{
public static function create(
?string $locale,
int $dateType = \IntlDateFormatter::FULL,
int $timeType = \IntlDateFormatter::FULL,
$timezone = null,
$calendar = null,
?string $pattern = null)
{
global $force_strftime;
if ($force_strftime)
{
// This will return an instance of the emulation
return new IntlDateFormatter($locale, $dateType, $timeType, $timezone, $calendar, $pattern);
}
else
{
// This will return an instance of the standard PHP class if the 'intl' extension is loaded,
// otherwise an instance of the emulation
return new \IntlDateFormatter($locale, $dateType, $timeType, $timezone, $calendar, $pattern);
}
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace MRBS\Intl;
class IntlDatePatternConverter
{
private const QUOTE_CHAR = "'";
private $formatter;
public function __construct(Formatter $formatter)
{
$this->formatter = $formatter;
}
public function convert(string $pattern) : string
{
// Parse the pattern
// See https://unicode-org.github.io/icu/userguide/format_parse/datetime/
// "Note: Any characters in the pattern that are not in the ranges of [‘a’..’z’] and
// [‘A’..’Z’] will be treated as quoted text. For instance, characters like ':', '.',
// ' ', '#' and '@' will appear in the resulting time text even they are not enclosed
// within single quotes. The single quote is used to ‘escape’ letters. Two single
// quotes in a row, whether inside or outside a quoted sequence, represent a ‘real’
// single quote."
$format = '';
$token = '';
$token_char = null;
$in_quotes = false;
// Split the string into an array of multibyte characters
$chars = preg_split("//u", $pattern, 0, PREG_SPLIT_NO_EMPTY);
while (null !== ($char = array_shift($chars)))
{
$is_token_char = !$in_quotes && preg_match("/^[a-z]$/i", $char);
if ($is_token_char)
{
// The start of a token
if (!isset($token_char))
{
$token_char = $char;
$token = $char;
}
// The continuation of a token
elseif ($char === $token_char)
{
$token .= $char;
}
// The end of a token and the beginning of a new one
else
{
$format .= $this->formatter->convert($token);
$token_char = $char;
$token = $char;
}
}
// Check to see if a token has just ended, ie we've either got
// a non-token character or there are no more characters left.
if (($token !== '') && (!$is_token_char || empty($chars)))
{
$format .= $this->formatter->convert($token);
$token = '';
$token_char = null;
}
// Quoted text
if (!$is_token_char)
{
// If it's not a quote just add the character to the format
if ($char !== self::QUOTE_CHAR)
{
$format .= $this->formatter->escape($char);
}
// Otherwise we have to work out whether the quote is the start or end of a
// quoted sequence, or part of an escaped quote
else
{
// Get the next character
$char = array_shift($chars);
if (isset($char))
{
// If it is a quote then it's an escaped quote and add it to the format
if ($char === self::QUOTE_CHAR)
{
$format .= $this->formatter->escape($char);
}
// Otherwise it's either the start or end of a quoted section.
// Toggle $in_quotes and add the character to the format if we're in quotes,
// or else replace it so that it gets handled properly next time round.
else
{
$in_quotes = !$in_quotes;
if ($in_quotes)
{
$format .= $this->formatter->escape($char);
}
else
{
array_unshift($chars, $char);
}
}
}
}
}
}
return $format;
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace MRBS\Intl;
// A class which is just a wrapper for the standard PHP class if it's available; otherwise it
// provides a basic emulation of PHP's IntlDatePatternGenerator class.
use MRBS\Exception;
use MRBS\Language;
// We need to check that the 'intl' extension is loaded because earlier versions of
// MRBS had the IntlDatePatternGenerator emulation class at the top level in lib. If users
// have upgraded by just overwriting files without deleting that file, then it will
// be picked up by the class_exists() test and used instead of the more up-to-date
// emulation below.
if (class_exists('\IntlDatePatternGenerator') && extension_loaded('intl'))
{
class IntlDatePatternGenerator extends \IntlDatePatternGenerator
{
}
}
else
{
class IntlDatePatternGenerator
{
private const DEFAULT_LOCALE = 'en';
private $locale;
// $locale The locale. If null is passed, uses the ini setting intl.default_locale.
public function __construct(?string $locale = null)
{
if (!isset($locale)) {
$locale = ini_get('intl.default_locale');
if (($locale === false) || ($locale === '')) {
throw new Exception("Could not get locale");
}
}
$this->locale = $locale;
}
public function getBestPattern(string $skeleton)
{
$file = MRBS_ROOT . "/intl/skeletons/$skeleton.ini";
if (is_readable($file)) {
$patterns = parse_ini_file($file);
if (!empty($patterns)) {
return $patterns[Language::convertToBcp47($this->locale)] ?? $patterns[self::DEFAULT_LOCALE] ?? false;
}
}
return false;
}
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
namespace MRBS\Intl;
class IntlException extends \Exception
{
}
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace MRBS\Intl;
/**
* A wrapper for the Locale class, that uses the PHP method if available, otherwise falling
* back to the emulator.
*/
class Locale
{
public const LANG_TAG = 'language';
public const EXTLANG_TAG = 'extlang';
public const SCRIPT_TAG = 'script';
public const REGION_TAG = 'region';
public const VARIANT_TAG = 'variant';
public const GRANDFATHERED_LANG_TAG = 'grandfathered';
public const PRIVATE_TAG = 'private';
public const ACTUAL_LOCALE = 0;
public const VALID_LOCALE = 1;
public static function __callStatic(string $name, array $arguments)
{
// Use the PHP method if it exists, unless it's the acceptFromHttp() method. Until we stop using setlocale(), we
// can't rely on PHP's acceptFromHttp() to tell us whether the locale is valid for setlocale() and have to use
// the emulator, which does further checks.
if (($name !== 'acceptFromHttp') && method_exists('\Locale', $name))
{
// Check that the method we're calling also exists in the emulator class, in case the 'intl' extension is not enabled.
assert(method_exists(__NAMESPACE__ . '\LocaleEmulator', $name), "Call to \Locale::$name which hasn't been emulated.");
return \Locale::$name(...$arguments);
}
return LocaleEmulator::$name(...$arguments);
}
}
+299
View File
@@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
namespace MRBS\Intl;
use InvalidArgumentException;
use MRBS\Language;
use MRBS\System;
/**
* A partial emulation of the PHP \Locale class.
*
* It is necessary because (a) the 'intl' extension isn't always enabled and (b) even if it is some
* methods are only available in later versions of PHP, eg isRightToLeft is only available from PHP 8.5
*/
class LocaleEmulator
{
/**
* A list of languages that use Right to Left text
*/
private const RTL_LANGUAGES = [
// TODO: Expand this list
'he'
];
/**
* Tries to find out best available locale based on HTTP "Accept-Language" header.
*
* @return string|false the corresponding locale identifier, or FALSE if none found
*/
public static function acceptFromHttp(string $header)
{
$accept_languages = self::toSortedArray($header);
foreach($accept_languages as $accept_language => $value)
{
if (System::isAvailableLocale($accept_language))
{
return $accept_language;
}
}
return false;
}
// Returns a correctly ordered and delimited locale ID
public static function composeLocale(array $subtags)
{
if (isset($subtags[Locale::GRANDFATHERED_LANG_TAG]))
{
return $subtags[Locale::GRANDFATHERED_LANG_TAG];
}
$pieces = array();
foreach (array(Locale::LANG_TAG, Locale::EXTLANG_TAG, Locale::SCRIPT_TAG, Locale::REGION_TAG) as $subtag_label)
{
if (isset($subtags[$subtag_label]))
{
$pieces[] = $subtags[$subtag_label];
}
}
if (array_key_exists(Locale::VARIANT_TAG . '0', $subtags))
{
for ($i=0; $i<15; $i++)
{
if (isset($subtags[Locale::VARIANT_TAG . $i]))
{
$pieces[] = $subtags[Locale::VARIANT_TAG . $i];
}
}
}
if (array_key_exists(Locale::PRIVATE_TAG . '0', $subtags))
{
$pieces[] = 'x';
for ($i=0; $i<15; $i++)
{
if (isset($subtags[Locale::PRIVATE_TAG . $i]))
{
$pieces[] = $subtags[Locale::PRIVATE_TAG . $i];
}
}
}
return implode('_', $pieces);
}
// Returns a key-value array of locale ID subtag elements.
// Parses a language tag according to BCP 47
// See http://tools.ietf.org/html/bcp47
public static function parseLocale(string $locale) : ?array
{
static $regex = array('extlang' => '/^[[:alpha:]]{3}$/', // 3ALPHA
'script' => '/^[[:alpha:]]{4}$/', // 4ALPHA
'region' => '/^[[:alpha:]]{2}$|^[[:digit:]]{3}$/', // 2ALPHA or 3DIGIT
'variant' => '/^[[:alnum:]]{5,8}$|^[[:digit:]][[:alnum:]]{3}$/'); // 5*8alphanum or (DIGIT 3alphanum)
static $grandfathered = array('en-GB-oed', // Irregular
'i-ami',
'i-bnn',
'i-default',
'i-enochian',
'i-hak',
'i-klingon',
'i-lux',
'i-mingo',
'i-navajo',
'i-pwn',
'i-tao',
'i-tay',
'i-tsu',
'sgn-BE-FR',
'sgn-BE-NL',
'sgn-CH-DE',
'art-lojban', // Regular
'cel-gaulish',
'no-bok',
'no-nyn',
'zh-guoyu',
'zh-hakka',
'zh-min',
'zh-min-nan',
'zh-xiang');
// First check for a grandfathered tag
if (isset($locale) && in_array($locale, $grandfathered))
{
return array(Locale::GRANDFATHERED_LANG_TAG => $locale);
}
// Otherwise parse the subtags
$result = array();
if (isset($locale))
{
$subtags = preg_split('/[-_]/', $locale);
}
else
{
$subtags = array();
}
while (null !== ($subtag = array_shift($subtags)))
{
// Tags are case-insensitive, so convert to lowercase before processing and then
// later convert as necessary according to convention
$subtag = strtolower($subtag);
if ($subtag == 'x')
{
// If the subtag is an 'x' then everything else is a private subtag,
// even if it occurs as the first subtag:
// "The single-character subtag 'x' as the primary subtag indicates
// that the language tag consists solely of subtags whose meaning is
// defined by private agreement"
$i = 0;
while (null !== ($subtag = array_shift($subtags)))
{
$result[Locale::PRIVATE_TAG . $i] = $subtag;
$i++;
}
}
// The primary language subtag is the first subtag in a language tag and
// cannot be omitted, with two exceptions:
//
// o The single-character subtag 'x' as the primary subtag ...
// o The single-character subtag 'i' is used by some grandfathered tags ...
elseif (!isset($result[Locale::LANG_TAG]))
{
// [ISO639-1] recommends that language codes be written in lowercase ('mn' Mongolian).
// As the subtag will already be lowercase there's no need to do anything else
$result[Locale::LANG_TAG] = $subtag;
// Check if the next subtag looks like a language extension
if (count($subtags) > 0)
{
if (preg_match($regex['extlang'], $subtags[0]))
{
$result[Locale::EXTLANG_TAG] = strtolower(array_shift($subtags));
}
}
}
// Script
elseif (preg_match($regex['script'], $subtag))
{
// [ISO15924] recommends that script codes use lowercase with the
// initial letter capitalized ('Cyrl' Cyrillic).
$result[Locale::SCRIPT_TAG] = ucfirst($subtag);
}
// Region
elseif (preg_match($regex['region'], $subtag))
{
// [ISO3166-1] recommends that country codes be capitalized ('MN'
// Mongolia).
$result[Locale::REGION_TAG] = strtoupper($subtag);
}
// Variants
elseif (preg_match($regex['variant'], $subtag))
{
$i = 0;
do
{
// If the subtag doesn't look like a variant then we've got them all
// and gone one subtag too far, so put it back
if (!preg_match($regex['variant'], $subtag))
{
array_unshift($subtags, $subtag);
break;
}
$result[Locale::VARIANT_TAG . $i] = $subtag;
}
while (null !== ($subtag = array_shift($subtags)));
}
else
{
trigger_error("parseLocale: could not parse subtag '$subtag'", E_USER_NOTICE);
// This is how the PHP version behaves: if it can't parse the locale completely
// it returns an empty array.
$result = array();
}
}
return $result;
}
public static function isRightToLeft(string $locale): bool
{
return in_array(mb_strtolower($locale), self::RTL_LANGUAGES);
}
// Searches the items in the array $langtag for the best match to the language range
// specified in $locale according to RFC 4647's lookup algorithm. The langtags and
// locale can have subtags separated by '-' or '_' and the search is case-insensitive.
// Charsets (eg '.UTF-8') are stripped off $locale
//
// Returns the best match, or else an empty string if no match
public static function lookup(array $langtag, string $locale, bool $canonicalize=false, ?string $default=null) : ?string
{
if ($canonicalize)
{
throw new InvalidArgumentException('MRBS: the MRBS version of Locale::lookup() does not yet support $canonicalize = true');
}
// Get the langtags and locale in the same format, ie separated by '-' and
// all lower case
$standard_langtags = self::standardise($langtag);
// Strip off any charset (eg '.UTF-8');
$locale = preg_replace('/\..*$/', '', $locale);
$standard_locale = self::standardise($locale);
// Look for a match. If there isn't one remove the last subtag from the end
// of the locale and try again.
while (false === ($index = array_search($standard_locale, $standard_langtags)))
{
if (false === ($pos = strrpos($standard_locale, '-')))
{
return (isset($default)) ? $default : '';
}
$standard_locale = substr($standard_locale, 0, $pos);
}
return $langtag[$index]; // Return the match in its original format
}
// Converts $langtag, which can be a string or an array, into a standard form with
// subtags all in lower case and separated by '-';
private static function standardise($langtag)
{
$glue = ',';
$result = (is_array($langtag)) ? implode($glue, $langtag) : $langtag;
$result = mb_strtolower(str_replace('_', '-', $result));
return (is_array($langtag)) ? explode($glue, $result) : $result;
}
/**
* Converts an Accept-Language request-header to an array of acceptable languages
* with the language as the key and the quality value as the value, sorted in
* decreasing order of quality value. A wildcard in the header is translated.
*
* @return array<string, float>
*/
private static function toSortedArray(string $header) : array
{
return Language::getQualifiers($header, true);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace MRBS\Intl;
use MRBS\Language;
use MRBS\System;
class LocaleSwitcher
{
private $category;
private $locale;
private $old_locale;
public function __construct(int $category, string $locale)
{
$this->category = $category;
$this->locale = $locale;
}
public function switch()
{
// It's not worth testing whether the new locale is the same as the old one, thereby saving us setting
// the locale again. That's because setting the locale on Unix systems seems to be about 100 times
// faster than on Windows. So on Unix systems, it's not worth worrying about. And on Windows, we have
// to set the locale again anyway in case another script running in the same process has changed the
// locale since we first set it. See the warning on the PHP manual page for setlocale():
//
// "The locale information is maintained per process, not per thread. If you are running PHP on a
// multithreaded server API like IIS or Apache on Windows, you may experience sudden changes in locale
// settings while a script is running, though the script itself never called setlocale(). This happens
// due to other scripts running in different threads of the same process at the same time, changing the
// process-wide locale using setlocale()."
$this->old_locale = setlocale($this->category, '0');
Language::setLocale($this->category, $this->locale);
}
public function restore()
{
if (!isset($this->old_locale))
{
throw new \RuntimeException("switch() must be called before restore().");
}
if (false === setlocale($this->category, $this->old_locale))
{
// Shouldn't happen as the old locale was what the system told us it was.
throw new \RuntimeException("Could not restore locale to '" . $this->old_locale . "'");
}
}
}
+211
View File
@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace MRBS\Intl;
/**
* A partial, very basic, emulation of the PHP \NumberFormatter class. Some methods are not implemented,
* and some attributes are not supported. This class is only intended as a very basic fallback if the
* intl extension is not available. For best results use the intl extension.
* @see \NumberFormatter
*/
class NumberFormatter
{
public const PATTERN_DECIMAL = 0;
public const DECIMAL = 1;
public const CURRENCY = 2;
public const PERCENT = 3;
public const SCIENTIFIC = 4;
public const SPELLOUT = 5;
public const ORDINAL = 6;
public const DURATION = 7;
public const PATTERN_RULEBASED = 9;
public const IGNORE = 0;
public const CURRENCY_ACCOUNTING = 12;
public const DECIMAL_COMPACT_SHORT = 14; // PHP 8.5 onwards
public const DECIMAL_COMPACT_LONG= 15; // PHP 8.5 onwards
public const DEFAULT_STYLE = 1;
public const ROUND_CEILING = 0;
public const ROUND_FLOOR = 1;
public const ROUND_DOWN = 2;
public const ROUND_UP = 3;
public const ROUND_TOWARD_ZERO = 2;
public const ROUND_AWAY_FROM_ZERO = 3;
public const ROUND_HALFEVEN = 4;
public const ROUND_HALFODD = 8;
public const ROUND_HALFDOWN = 5;
public const ROUND_HALFUP = 6;
public const PAD_BEFORE_PREFIX = 0;
public const PAD_AFTER_PREFIX = 1;
public const PAD_BEFORE_SUFFIX = 2;
public const PAD_AFTER_SUFFIX = 3;
public const PARSE_INT_ONLY = 0;
public const GROUPING_USED = 1;
public const DECIMAL_ALWAYS_SHOWN = 2;
public const MAX_INTEGER_DIGITS = 3;
public const MIN_INTEGER_DIGITS = 4;
public const INTEGER_DIGITS = 5;
public const MAX_FRACTION_DIGITS = 6;
public const MIN_FRACTION_DIGITS = 7;
public const FRACTION_DIGITS = 8;
public const MULTIPLIER = 9;
public const GROUPING_SIZE = 10;
public const ROUNDING_MODE = 11;
public const ROUNDING_INCREMENT = 12;
public const FORMAT_WIDTH = 13;
public const PADDING_POSITION = 14;
public const SECONDARY_GROUPING_SIZE = 15;
public const SIGNIFICANT_DIGITS_USED = 16;
public const MIN_SIGNIFICANT_DIGITS = 17;
public const MAX_SIGNIFICANT_DIGITS = 18;
public const LENIENT_PARSE = 19;
public const POSITIVE_PREFIX = 0;
public const POSITIVE_SUFFIX = 1;
public const NEGATIVE_PREFIX = 2;
public const NEGATIVE_SUFFIX = 3;
public const PADDING_CHARACTER = 4;
public const CURRENCY_CODE = 5;
public const DEFAULT_RULESET = 6;
public const PUBLIC_RULESETS = 7;
public const DECIMAL_SEPARATOR_SYMBOL = 0;
public const GROUPING_SEPARATOR_SYMBOL = 1;
public const PATTERN_SEPARATOR_SYMBOL = 2;
public const PERCENT_SYMBOL = 3;
public const ZERO_DIGIT_SYMBOL = 4;
public const DIGIT_SYMBOL = 5;
public const MINUS_SIGN_SYMBOL = 6;
public const PLUS_SIGN_SYMBOL = 7;
public const CURRENCY_SYMBOL = 8;
public const INTL_CURRENCY_SYMBOL = 9;
public const MONETARY_SEPARATOR_SYMBOL = 10;
public const EXPONENTIAL_SYMBOL = 11;
public const PERMILL_SYMBOL = 12;
public const PAD_ESCAPE_SYMBOL = 13;
public const INFINITY_SYMBOL = 14;
public const NAN_SYMBOL = 15;
public const SIGNIFICANT_DIGIT_SYMBOL = 16;
public const MONETARY_GROUPING_SEPARATOR_SYMBOL = 17;
public const TYPE_DEFAULT = 0;
public const TYPE_INT32 = 1;
public const TYPE_INT64 = 2;
public const TYPE_DOUBLE = 3;
public const TYPE_CURRENCY = 4;
public const CURRENCY_ISO = 10; // PHP 8.5 onwards
public const CURRENCY_PLURAL = 11; // PHP 8.5 onwards
public const CASH_CURRENCY = 13; // PHP 8.5 onwards
public const CURRENCY_STANDARD = 16; // PHP 8.5 onwards
public const VALID_ATTRIBUTES = [
self::PARSE_INT_ONLY,
self::GROUPING_USED,
self::DECIMAL_ALWAYS_SHOWN,
self::MAX_INTEGER_DIGITS,
self::MIN_INTEGER_DIGITS,
self::INTEGER_DIGITS,
self::MAX_FRACTION_DIGITS,
self::MIN_FRACTION_DIGITS,
self::FRACTION_DIGITS,
self::MULTIPLIER,
self::GROUPING_SIZE,
self::ROUNDING_MODE,
self::ROUNDING_INCREMENT,
self::FORMAT_WIDTH,
self::PADDING_POSITION,
self::SECONDARY_GROUPING_SIZE,
self::SIGNIFICANT_DIGITS_USED,
self::MIN_SIGNIFICANT_DIGITS,
self::MAX_SIGNIFICANT_DIGITS,
self::LENIENT_PARSE
];
public const VALID_STYLES = [
self::PATTERN_DECIMAL,
self::DECIMAL,
self::CURRENCY,
self::PERCENT,
self::SCIENTIFIC,
self::SPELLOUT,
self::ORDINAL,
self::DURATION,
self::PATTERN_RULEBASED,
self::CURRENCY_ACCOUNTING,
self::DEFAULT_STYLE,
self::IGNORE
];
public const VALID_TEXT_ATTRIBUTES = [
self::POSITIVE_PREFIX,
self::POSITIVE_SUFFIX,
self::NEGATIVE_PREFIX,
self::NEGATIVE_SUFFIX,
self::PADDING_CHARACTER,
self::CURRENCY_CODE,
self::DEFAULT_RULESET,
self::PUBLIC_RULESETS
];
private $attributes;
private $locale;
private $style;
private $text_attributes;
/**
* @see \NumberFormatter::__construct()
*/
public function __construct(string $locale, int $style, ?string $pattern = null)
{
$this->locale = $locale;
if (!in_array($style, self::VALID_STYLES, true))
{
throw new \IntlException(str_replace(__NAMESPACE__ . '\\', '', __METHOD__) . '(): number formatter creation failed');
}
$this->style = $style;
}
/**
* @see \NumberFormatter::format()
*/
public function format($num, int $type = self::TYPE_DEFAULT)
{
$locale_switcher = new LocaleSwitcher(LC_NUMERIC, $this->locale);
$locale_switcher->switch();
$locale_info = localeconv();
$locale_switcher->restore();
return number_format($num, 0, $locale_info['decimal_point'], $locale_info['thousands_sep']);
}
/**
* @see \NumberFormatter::setAttribute()
*/
public function setAttribute(int $attribute, $value): bool
{
if (!in_array($attribute, self::VALID_ATTRIBUTES, true))
{
return false;
}
$this->attributes[$attribute] = $value;
return true;
}
/**
* @see \NumberFormatter::setTextAttribute()
*/
public function setTextAttribute(int $attribute, string $value): bool
{
if (!in_array($attribute, self::VALID_TEXT_ATTRIBUTES, true))
{
return false;
}
$this->text_attributes[$attribute] = $value;
return true;
}
}