0x1C8A, 0xA7CB => 0x0264, 0xA7CC => 0xA7CD, 0xA7DA => 0xA7DB, 0xA7DC => 0x019B, 0x10D50 => 0x10D70, 0x10D51 => 0x10D71, 0x10D52 => 0x10D72, 0x10D53 => 0x10D73, 0x10D54 => 0x10D74, 0x10D55 => 0x10D75, 0x10D56 => 0x10D76, 0x10D57 => 0x10D77, 0x10D58 => 0x10D78, 0x10D59 => 0x10D79, 0x10D5A => 0x10D7A, 0x10D5B => 0x10D7B, 0x10D5C => 0x10D7C, 0x10D5D => 0x10D7D, 0x10D5E => 0x10D7E, 0x10D5F => 0x10D7F, 0x10D60 => 0x10D80, 0x10D61 => 0x10D81, 0x10D62 => 0x10D82, 0x10D63 => 0x10D83, 0x10D64 => 0x10D84, 0x10D65 => 0x10D85 ]; /** * A list of codepoints where the Transliterator provides a different result to * the mbstring library when converting to uppercase. */ private const TRANSLITERATOR_UPPER_EXCEPTIONS = [ 0x019B => 0xA7DC, // LATIN SMALL LETTER LAMBDA WITH STROKE 0x0264 => 0xA7CB, // LATIN SMALL LETTER RAMS HORN 0x1C8A => 0x1C89, 0xA7CD => 0xA7CC, 0xA7DB => 0xA7DA, 0x10D70 => 0x10D50, 0x10D71 => 0x10D51, 0x10D72 => 0x10D52, 0x10D73 => 0x10D53, 0x10D74 => 0x10D54, 0x10D75 => 0x10D55, 0x10D76 => 0x10D56, 0x10D77 => 0x10D57, 0x10D78 => 0x10D58, 0x10D79 => 0x10D59, 0x10D7A => 0x10D5A, 0x10D7B => 0x10D5B, 0x10D7C => 0x10D5C, 0x10D7D => 0x10D5D, 0x10D7E => 0x10D5E, 0x10D7F => 0x10D5F, 0x10D80 => 0x10D60, 0x10D81 => 0x10D61, 0x10D82 => 0x10D62, 0x10D83 => 0x10D63, 0x10D84 => 0x10D64, 0x10D85 => 0x10D65 ]; /** * Emulates mb_chr(). Only UTF-8 is supported. * * @return false|string */ public static function mb_chr(int $codepoint, ?string $encoding = null) { if (isset($encoding) && (strtoupper($encoding) !== 'UTF-8')) { $message = "This emulation of " . __FUNCTION__ . "() only supports the UTF-8 encoding."; throw new InvalidArgumentException($message); } // Check if it's a codepoint reserved for surrogates. if ((0xD800 <= $codepoint) && ($codepoint <= 0xDFFF)) { return false; } // Use IntlChar if possible - it's the fastest method. if (method_exists('IntlChar', 'chr')) { return IntlChar::chr($codepoint); } // If not, use iconv(), if we can, which takes about 3 times as long as IntlChar. if (function_exists('iconv')) { return iconv('UCS-4LE', 'UTF-8', pack('V', $codepoint)); } // Otherwise, use json_decode() which takes about 5 times as long as IntlChar. return json_decode('"' . self::FullJsonEscapedCodepoint($codepoint) . '"') ?? false; } /** * Emulates mb_ord(). Only UTF-8 is supported. * * @return false|int */ public static function mb_ord(string $string, ?string $encoding=null) { if (isset($encoding) && (strtoupper($encoding) !== 'UTF-8')) { $message = "This emulation of " . __FUNCTION__ . "() only supports the UTF-8 encoding."; throw new InvalidArgumentException($message); } // Use IntlChar if possible - it's the fastest method. if (method_exists('IntlChar', 'ord')) { return IntlChar::ord($string) ?? false; } // Taken from https://stackoverflow.com/questions/9361303/can-i-get-the-unicode-value-of-a-character-or-vise-versa-with-php // If not, use iconv() if we can, which takes about 2.3 times as long as IntlChar. if (function_exists('iconv')) { return unpack('V', iconv('UTF-8', 'UCS-4LE', $string))[1]; } // Otherwise do it the long way, which takes about 2.6 times as long as IntlChar. if (ord($string[0]) >= 0 && ord($string[0]) <= 127) { return ord($string[0]); } if (ord($string[0]) >= 192 && ord($string[0]) <= 223) { return (ord($string[0]) - 192) * 64 + (ord($string[1]) - 128); } if (ord($string[0]) >= 224 && ord($string[0]) <= 239) { return (ord($string[0]) - 224) * 4096 + (ord($string[1]) - 128) * 64 + (ord($string[2]) - 128); } if (ord($string[0]) >= 240 && ord($string[0]) <= 247) { return (ord($string[0]) - 240) * 262144 + (ord($string[1]) - 128) * 4096 + (ord($string[2]) - 128) * 64 + (ord($string[3]) - 128); } if (ord($string[0]) >= 248 && ord($string[0]) <= 251) { return (ord($string[0]) - 248) * 16777216 + (ord($string[1]) - 128) * 262144 + (ord($string[2]) - 128) * 4096 + (ord($string[3]) - 128) * 64 + (ord($string[4]) - 128); } if (ord($string[0]) >= 252 && ord($string[0]) <= 253) { return (ord($string[0]) - 252) * 1073741824 + (ord($string[1]) - 128) * 16777216 + (ord($string[2]) - 128) * 262144 + (ord($string[3]) - 128) * 4096 + (ord($string[4]) - 128) * 64 + (ord($string[5]) - 128); } if (ord($string[0]) >= 254 && ord($string[0]) <= 255) // error { return false; } return 0; } /** * Emulates mb_regex_encoding(). Only UTF-8 is supported. * * @return string|true */ public static function mb_regex_encoding(?string $encoding=null) { if (!isset($encoding)) { return 'UTF-8'; } if (strtoupper($encoding) === 'UTF-8') { return true; } $message = "This emulation of " . __FUNCTION__ . "() only supports the UTF-8 encoding."; throw new InvalidArgumentException($message); } /** * Emulates mb_split(). Only UTF-8 is supported. * * @return array|false|string[] */ public static function mb_split(string $pattern, string $string, int $limit = -1) { return preg_split('/' . $pattern . '/u', $string, $limit); } /** * Emulates mb_stripos(). Only UTF-8 is supported. * * @return false|int * @throws ValueError if **offset** is greater than the length of the **haystack** (PHP 8.0 onwards) */ public static function mb_stripos(string $haystack, string $needle, int $offset=0, ?string $encoding=null) { if (isset($encoding) && (strtoupper($encoding) !== 'UTF-8')) { $message = "This emulation of " . __FUNCTION__ . "() only supports the UTF-8 encoding."; throw new InvalidArgumentException($message); } // We could just convert $haystack and $needle to the same case and then use mb_strpos. // However, that would involve converting the whole of both strings, whereas Utf8StrposGeneric() // only converts those characters that are necessary. return self::Utf8StrposGeneric($haystack, $needle, $offset, true); } /** * Emulates mb_strpos(). Only UTF-8 is supported. * * @return false|int * @throws ValueError if **offset** is greater than the length of the **haystack** (PHP 8.0 onwards) */ public static function mb_strpos(string $haystack, string $needle, int $offset=0, ?string $encoding=null) { if (isset($encoding) && (strtoupper($encoding) !== 'UTF-8')) { $message = "This emulation of " . __FUNCTION__ . "() only supports the UTF-8 encoding."; throw new InvalidArgumentException($message); } return self::Utf8StrposGeneric($haystack, $needle, $offset); } /** * Emulates mb_strripos(). Only UTF-8 is supported. * * @return false|int * @throws ValueError if **offset** is greater than the length of the **haystack** (PHP 8.0 onwards) */ public static function mb_strripos(string $haystack, string $needle, int $offset=0, ?string $encoding=null) { if (isset($encoding) && (strtoupper($encoding) !== 'UTF-8')) { $message = "This emulation of " . __FUNCTION__ . "() only supports the UTF-8 encoding."; throw new InvalidArgumentException($message); } return self::Utf8StrposGeneric($haystack, $needle, $offset, true, true); } /** * Emulates mb_strrpos(). Only UTF-8 is supported. * * @return false|int * @throws ValueError if **offset** is greater than the length of the **haystack** (PHP 8.0 onwards) */ public static function mb_strrpos(string $haystack, string $needle, int $offset=0, ?string $encoding=null) { if (isset($encoding) && (strtoupper($encoding) !== 'UTF-8')) { $message = "This emulation of " . __FUNCTION__ . "() only supports the UTF-8 encoding."; throw new InvalidArgumentException($message); } return self::Utf8StrposGeneric($haystack, $needle, $offset, false, true); } /** * Emulates mb_strlen(). Only UTF-8, utf-8 and 8bit are supported. */ public static function mb_strlen(string $string, ?string $encoding=null) : int { if ($string === '') { return 0; } switch ($encoding) { case null: case 'UTF-8': case 'utf-8': $result = count((new Utf8String($string))->toArray()); break; case '8bit': $result = (new Utf8String($string))->byteCount(); break; default: throw new InvalidArgumentException("Encoding '$encoding' is not supported."); break; } return $result; } /** * Emulates mb_strtolower(). Only UTF-8 is supported. */ public static function mb_strtolower(string $string, ?string $encoding=null) : string { if ($string === '') { return $string; } if (isset($encoding) && (strtoupper($encoding) !== 'UTF-8')) { $message = "This emulation of " . __FUNCTION__ . "() only supports the UTF-8 encoding."; throw new InvalidArgumentException($message); } // If we can, use the Transliterator if (method_exists('Transliterator', 'transliterate')) { // Works better than IntlChar::toLower() // See https://stackoverflow.com/questions/79655507/what-is-the-difference-between-phps-mb-strolower-and-intlchartolower return self::TransliteratorToLower($string); } // Otherwise, use the (enhanced) ordinary strtolower(). return Ordinary::strtolower($string); } /** * Emulates mb_strtoupper(). Only UTF-8 is supported. */ public static function mb_strtoupper(string $string, ?string $encoding=null) : string { if ($string === '') { return $string; } if (isset($encoding) && (strtoupper($encoding) !== 'UTF-8')) { $message = "This emulation of " . __FUNCTION__ . "() only supports the UTF-8 encoding."; throw new InvalidArgumentException($message); } // If we can, use the Transliterator if (method_exists('Transliterator', 'transliterate')) { // Works better than IntlChar::toUpper() // See https://stackoverflow.com/questions/79655507/what-is-the-difference-between-phps-mb-strolower-and-intlchartolower return self::TransliteratorToUpper($string); } // Otherwise, use the (enhanced) ordinary strtoupper(). return Ordinary::strtoupper($string); } /** * Emulates mb_substr(). Only UTF-8 is supported. */ public static function mb_substr(string $string, int $start, ?int $length = null, ?string $encoding = null): string { if (isset($encoding) && (strtoupper($encoding) !== 'UTF-8')) { $message = "This emulation of " . __FUNCTION__ . "() only supports the UTF-8 encoding."; throw new InvalidArgumentException($message); } return implode('', array_slice((new Utf8String($string))->toArray(), $start, $length)); } /** * A generic emulation of mb_strpos(), mb_stripos(), mb_strrpos() and mb_strripos() for UTF-8 * that has the case-sensitivity and direction as parameters. * This function can be tested by test_mb.php in the mrbs-tools repository. * * @return false|int * @throws ValueError if **offset** is greater than the length of the **haystack** (PHP 8.0 onwards) */ private static function Utf8StrposGeneric(string $haystack, string $needle, int $offset=0, bool $case_insensitive=false, bool $reverse=false) { $haystack_chars = (new Utf8String($haystack))->toArray(); $last_haystack_index = count($haystack_chars) - 1; $needle_chars = (new Utf8String($needle))->toArray(); $last_needle_index = count($needle_chars) - 1; if ($reverse) { // We're looking for the last occurrence of the needle in the haystack, so we // start at the right-hand end of the haystack, going backwards through the // haystack looking for the right-hand end of the needle. If we find the end // of the needle, we keep going backwards through the needle and haystack, // checking that all the characters match. $increment = -1; if ($offset < 0) { // The PHP mb_strrpos() documentation isn't very clear. If there's a negative offset // then it looks for a needle that can *start* anywhere up to and including the offset // character. It doesn't matter if part of the needle is to the right of the offset. $haystack_start = min($last_haystack_index, $last_haystack_index + count($needle_chars) + $offset); $haystack_end = 0; } else { $haystack_start = $last_haystack_index; $haystack_end = $offset; } $needle_start = $last_needle_index; $needle_end = 0; } else { // We're looking for the first occurrence of the needle in the haystack, so we // start at the left-hand end of the haystack, going forwards through the // haystack looking for the left-hand end of the needle. If we find the end // of the needle, we keep going forwards through the needle and haystack, // checking that all the characters match. $increment = 1; // Note the +1 for negative offsets n the line below: it's the way the PHP implementation works $haystack_start = ($offset < 0) ? $last_haystack_index + $offset + 1 : $offset; $haystack_end = $last_haystack_index; $needle_start = 0; $needle_end = $last_needle_index; } // Check that $offset is sensible. (The PHP implementation allows an offset of +/-N on a // string of length N. The error reporting in the PHP implementation depends on the PHP version.) if (abs($offset) > $last_haystack_index + 1) { $message = __FUNCTION__ . '(): Argument #3 ($offset) must be contained in argument #1 ($haystack)'; if ((version_compare(PHP_VERSION, '8') >= 0)) { throw new ValueError($message); } else { $message = __FUNCTION__ . '(): Offset not contained in string'; trigger_error($message, E_USER_WARNING); return false; } } // Special case: an empty needle is found immediately if ($needle === '') { return $haystack_start; } // Quick test: if the needle is longer than the part of the haystack to // be searched, then we'll never find it. if ($reverse) { if ($offset >= 0) { if ((count($needle_chars) > $haystack_start + 2 - $haystack_end)) { return false; } } elseif (count($needle_chars) > count($haystack_chars)) { return false; } } elseif (count($needle_chars) > $haystack_end + 1 - $haystack_start) { return false; } // Otherwise, start searching through the haystack for the needle (going either backwards // or forwards depending on the value of $increment). $n = $needle_start; for ($h = $haystack_start; ($increment > 0) ? $h <= $haystack_end : $h >= $haystack_end; $h += $increment) { // If we get a match with a needle character, then keep cycling through // the haystack and needle. If we get to the end of the needle and all // characters have matched, then we've found the needle; otherwise we // reset and start looking for the needle again. while ((($increment > 0) ? $h <= $haystack_end : $h >= $haystack_end) && isset($needle_chars[$n]) && self::isStringEqual($haystack_chars[$h], $needle_chars[$n], $case_insensitive)) { if ($n === $needle_end) { // We've found the needle, as we've got to the end and all characters match return $h - $n; // to get the starting index of the needle. } $h += $increment; $n += $increment; } // No match, reset the needle index $n = $needle_start; } return false; } private static function TransliteratorToLower(string $string) : string { // There are some characters that the Transliterator treats differently from the mbstring // library, so we have to split the string into characters and deal with them one by one. // Remember, we are only trying to emulate the mbstring library, not provide the "correct" // result. $result = ''; $utf8_string = new Utf8String($string); foreach ($utf8_string->toArray() as $char) { $codepoint = IntlChar::ord($char); if (array_key_exists($codepoint, self::TRANSLITERATOR_LOWER_EXCEPTIONS)) { $result .= IntlChar::chr(self::TRANSLITERATOR_LOWER_EXCEPTIONS[$codepoint]); } else { $result .= Transliterator::create('Lower')->transliterate($char); } } return $result; } private static function TransliteratorToUpper(string $string) : string { // There are some characters that the Transliterator treats differently from the mbstring // library, so we have to split the string into characters and deal with them one by one. // Remember, we are only trying to emulate the mbstring library, not provide the "correct" // result. $result = ''; $utf8_string = new Utf8String($string); foreach ($utf8_string->toArray() as $char) { $codepoint = IntlChar::ord($char); if (array_key_exists($codepoint, self::TRANSLITERATOR_UPPER_EXCEPTIONS)) { $result .= IntlChar::chr(self::TRANSLITERATOR_UPPER_EXCEPTIONS[$codepoint]); } else { $result .= Transliterator::create('Upper')->transliterate($char); } } return $result; } private static function SingleJsonEscapedCodepoint(int $codepoint) : string { return '\u' . str_pad(strtoupper(dechex($codepoint)), 4, '0', STR_PAD_LEFT); } private static function FullJsonEscapedCodepoint(int $codepoint) : string { if ($codepoint > 0x10FFFF) { throw new InvalidArgumentException('Codepoint cannot be greater than 0x10FFFF'); } // The simple case: codepoints in the Basic Multilingual Plane if ($codepoint < 0x10000) { return self::SingleJsonEscapedCodepoint($codepoint); } // Otherwise, we have to split the codepoint into surrogate pairs. See https://en.wikipedia.org/wiki/UTF-16 // Subtract 0x10000, leaving a 20-bit number $codepoint -= 0x10000; // For the leading surrogate, take the high 10 bits and add 0xd800 $leading = self::SingleJsonEscapedCodepoint(($codepoint >> 10) + 0xD800); // For the trailing surrogate, take the low 10 bits and add 0xdc00 $trailing = self::SingleJsonEscapedCodepoint(($codepoint & 0x3FF) + 0xDC00); return $leading . $trailing; } /** * Test to see if two strings are equal, optionally case-insensitively. * See https://stackoverflow.com/questions/5473542/case-insensitive-string-comparison */ private static function isStringEqual(string $string1, string $string2, bool $case_insensitive=false) : bool { // Case-sensitive test if (!$case_insensitive) { return ($string1 === $string2); } // Case-insensitive // Quick test to see if they are equal in a case-sensitive fashion. if ($string1 === $string2) { return true; } // Otherwise do a case-insensitive check if (method_exists('Normalizer', 'normalize')) { $string1 = Normalizer::normalize($string1, Normalizer::FORM_KC); $string2 = Normalizer::normalize($string2, Normalizer::FORM_KC); } return ((mb_strtolower($string1) === mb_strtolower($string2)) || (mb_strtoupper($string1) === mb_strtoupper($string2))); } }