diff --git a/README.md b/README.md index 121056f1..bd266500 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ Polyfills are provided for: - the `Random\Engine\Secure` class introduced in PHP 8.2 (check [arokettu/random-polyfill](https://packagist.org/packages/arokettu/random-polyfill) for more engines); - the `json_validate` function introduced in PHP 8.3; - the `Override` attribute introduced in PHP 8.3; +- the `mb_str_pad` function introduced in PHP 8.3; It is strongly recommended to upgrade your PHP version and/or install the missing extensions whenever possible. This polyfill should be used only when there is no diff --git a/src/Mbstring/Mbstring.php b/src/Mbstring/Mbstring.php index 00f4ec17..2e0b9694 100644 --- a/src/Mbstring/Mbstring.php +++ b/src/Mbstring/Mbstring.php @@ -827,6 +827,50 @@ public static function mb_ord($s, $encoding = null) return $code; } + public static function mb_str_pad(string $string, int $length, string $pad_string = ' ', int $pad_type = \STR_PAD_RIGHT, string $encoding = null): string + { + if (!\in_array($pad_type, [\STR_PAD_RIGHT, \STR_PAD_LEFT, \STR_PAD_BOTH], true)) { + throw new \ValueError('mb_str_pad(): Argument #4 ($pad_type) must be STR_PAD_LEFT, STR_PAD_RIGHT, or STR_PAD_BOTH'); + } + + if (null === $encoding) { + $encoding = self::mb_internal_encoding(); + } + + try { + $validEncoding = @self::mb_check_encoding('', $encoding); + } catch (\ValueError $e) { + throw new \ValueError(sprintf('mb_str_pad(): Argument #5 ($encoding) must be a valid encoding, "%s" given', $encoding)); + } + + // BC for PHP 7.3 and lower + if (!$validEncoding) { + throw new \ValueError(sprintf('mb_str_pad(): Argument #5 ($encoding) must be a valid encoding, "%s" given', $encoding)); + } + + if (self::mb_strlen($pad_string, $encoding) <= 0) { + throw new \ValueError('mb_str_pad(): Argument #3 ($pad_string) must be a non-empty string'); + } + + $paddingRequired = $length - self::mb_strlen($string, $encoding); + + if ($paddingRequired < 1) { + return $string; + } + + switch ($pad_type) { + case \STR_PAD_LEFT: + return self::mb_substr(str_repeat($pad_string, $paddingRequired), 0, $paddingRequired, $encoding).$string; + case \STR_PAD_RIGHT: + return $string.self::mb_substr(str_repeat($pad_string, $paddingRequired), 0, $paddingRequired, $encoding); + default: + $leftPaddingLength = floor($paddingRequired / 2); + $rightPaddingLength = $paddingRequired - $leftPaddingLength; + + return self::mb_substr(str_repeat($pad_string, $leftPaddingLength), 0, $leftPaddingLength, $encoding).$string.self::mb_substr(str_repeat($pad_string, $rightPaddingLength), 0, $rightPaddingLength, $encoding); + } + } + private static function getSubpart($pos, $part, $haystack, $encoding) { if (false === $pos) { diff --git a/src/Mbstring/bootstrap.php b/src/Mbstring/bootstrap.php index 1fedd1f7..ecf1a035 100644 --- a/src/Mbstring/bootstrap.php +++ b/src/Mbstring/bootstrap.php @@ -132,6 +132,10 @@ function mb_scrub($string, $encoding = null) { $encoding = null === $encoding ? function mb_str_split($string, $length = 1, $encoding = null) { return p\Mbstring::mb_str_split($string, $length, $encoding); } } +if (!function_exists('mb_str_pad')) { + function mb_str_pad(string $string, int $length, string $pad_string = ' ', int $pad_type = STR_PAD_RIGHT, ?string $encoding = null): string { return p\Mbstring::mb_str_pad($string, $length, $pad_string, $pad_type, $encoding); } +} + if (extension_loaded('mbstring')) { return; } diff --git a/src/Mbstring/bootstrap80.php b/src/Mbstring/bootstrap80.php index 82f5ac4d..2f9fb5b4 100644 --- a/src/Mbstring/bootstrap80.php +++ b/src/Mbstring/bootstrap80.php @@ -128,6 +128,10 @@ function mb_scrub(?string $string, ?string $encoding = null): string { $encoding function mb_str_split(?string $string, ?int $length = 1, ?string $encoding = null): array { return p\Mbstring::mb_str_split((string) $string, (int) $length, $encoding); } } +if (!function_exists('mb_str_pad')) { + function mb_str_pad(string $string, int $length, string $pad_string = ' ', int $pad_type = STR_PAD_RIGHT, ?string $encoding = null): string { return p\Mbstring::mb_str_pad($string, $length, $pad_string, $pad_type, $encoding); } +} + if (extension_loaded('mbstring')) { return; } diff --git a/src/Php83/Php83.php b/src/Php83/Php83.php index 4953796b..23a9cf38 100644 --- a/src/Php83/Php83.php +++ b/src/Php83/Php83.php @@ -38,4 +38,48 @@ public static function json_validate(string $json, int $depth = 512, int $flags return \JSON_ERROR_NONE === json_last_error(); } + + public static function mb_str_pad(string $string, int $length, string $pad_string = ' ', int $pad_type = \STR_PAD_RIGHT, string $encoding = null): string + { + if (!\in_array($pad_type, [\STR_PAD_RIGHT, \STR_PAD_LEFT, \STR_PAD_BOTH], true)) { + throw new \ValueError('mb_str_pad(): Argument #4 ($pad_type) must be STR_PAD_LEFT, STR_PAD_RIGHT, or STR_PAD_BOTH'); + } + + if (null === $encoding) { + $encoding = mb_internal_encoding(); + } + + try { + $validEncoding = @mb_check_encoding('', $encoding); + } catch (\ValueError $e) { + throw new \ValueError(sprintf('mb_str_pad(): Argument #5 ($encoding) must be a valid encoding, "%s" given', $encoding)); + } + + // BC for PHP 7.3 and lower + if (!$validEncoding) { + throw new \ValueError(sprintf('mb_str_pad(): Argument #5 ($encoding) must be a valid encoding, "%s" given', $encoding)); + } + + if (mb_strlen($pad_string, $encoding) <= 0) { + throw new \ValueError('mb_str_pad(): Argument #3 ($pad_string) must be a non-empty string'); + } + + $paddingRequired = $length - mb_strlen($string, $encoding); + + if ($paddingRequired < 1) { + return $string; + } + + switch ($pad_type) { + case \STR_PAD_LEFT: + return mb_substr(str_repeat($pad_string, $paddingRequired), 0, $paddingRequired, $encoding).$string; + case \STR_PAD_RIGHT: + return $string.mb_substr(str_repeat($pad_string, $paddingRequired), 0, $paddingRequired, $encoding); + default: + $leftPaddingLength = floor($paddingRequired / 2); + $rightPaddingLength = $paddingRequired - $leftPaddingLength; + + return mb_substr(str_repeat($pad_string, $leftPaddingLength), 0, $leftPaddingLength, $encoding).$string.mb_substr(str_repeat($pad_string, $rightPaddingLength), 0, $rightPaddingLength, $encoding); + } + } } diff --git a/src/Php83/README.md b/src/Php83/README.md index e7d18731..2678ab8b 100644 --- a/src/Php83/README.md +++ b/src/Php83/README.md @@ -5,6 +5,7 @@ This component provides features added to PHP 8.3 core: - [`json_validate`](https://wiki.php.net/rfc/json_validate) - [`Override`](https://wiki.php.net/rfc/marking_overriden_methods) +- [`mb_str_pad`](https://wiki.php.net/rfc/mb_str_pad) More information can be found in the [main Polyfill README](https://github.com/symfony/polyfill/blob/main/README.md). diff --git a/src/Php83/bootstrap.php b/src/Php83/bootstrap.php index 3b8ccc8b..b474fc42 100644 --- a/src/Php83/bootstrap.php +++ b/src/Php83/bootstrap.php @@ -18,3 +18,7 @@ if (!function_exists('json_validate')) { function json_validate(string $json, int $depth = 512, int $flags = 0): bool { return p\Php83::json_validate($json, $depth, $flags); } } + +if (!function_exists('mb_str_pad') && function_exists('mb_substr')) { + function mb_str_pad(string $string, int $length, string $pad_string = ' ', int $pad_type = STR_PAD_RIGHT, ?string $encoding = null): string { return p\Php83::mb_str_pad($string, $length, $pad_string, $pad_type, $encoding); } +} diff --git a/tests/Mbstring/MbstringTest.php b/tests/Mbstring/MbstringTest.php index 4170bdd4..2499bed1 100644 --- a/tests/Mbstring/MbstringTest.php +++ b/tests/Mbstring/MbstringTest.php @@ -627,4 +627,104 @@ public function testDecodeMimeheader() $this->assertSame(sprintf('Test: %s', base64_decode('7/Du4uXw6uA=')), mb_decode_mimeheader('Test: =?windows-1251?B?7/Du4uXw6uA=?=')); $this->assertTrue(mb_internal_encoding('utf8')); } + + /** + * @covers \Symfony\Polyfill\Mbstring\Mbstring::mb_str_pad + * + * @dataProvider paddingStringProvider + * @dataProvider paddingEmojiProvider + * @dataProvider paddingEncodingProvider + */ + public function testMbStrPad(string $expectedResult, string $string, int $length, string $padString, int $padType, string $encoding = null): void + { + if ('UTF-32' === $encoding && \PHP_VERSION_ID < 73000) { + $this->markTestSkipped('PHP < 7.3 doesn\'t handle UTF-32 encoding properly'); + } + + $this->assertSame($expectedResult, mb_convert_encoding(mb_str_pad($string, $length, $padString, $padType, $encoding), 'UTF-8', $encoding ?? mb_internal_encoding())); + } + + /** + * @covers \Symfony\Polyfill\Mbstring\Mbstring::mb_str_pad + * + * @dataProvider mbStrPadInvalidArgumentsProvider + */ + public function testMbStrPadInvalidArguments(string $expectedError, string $string, int $length, string $padString, int $padType, string $encoding = null): void + { + $this->expectException(\ValueError::class); + $this->expectErrorMessage($expectedError); + + mb_str_pad($string, $length, $padString, $padType, $encoding); + } + + public static function paddingStringProvider(): iterable + { + // Simple ASCII strings + yield ['+Hello+', 'Hello', 7, '+-', \STR_PAD_BOTH]; + yield ['+-World+-+', 'World', 10, '+-', \STR_PAD_BOTH]; + yield ['+-Hello', 'Hello', 7, '+-', \STR_PAD_LEFT]; + yield ['+-+-+World', 'World', 10, '+-', \STR_PAD_LEFT]; + yield ['Hello+-', 'Hello', 7, '+-', \STR_PAD_RIGHT]; + yield ['World+-+-+', 'World', 10, '+-', \STR_PAD_RIGHT]; + // Edge cases pad length + yield ['▶▶', '▶▶', 2, ' ', \STR_PAD_BOTH]; + yield ['▶▶', '▶▶', 1, ' ', \STR_PAD_BOTH]; + yield ['▶▶', '▶▶', 0, ' ', \STR_PAD_BOTH]; + yield ['▶▶', '▶▶', -1, ' ', \STR_PAD_BOTH]; + // Empty input string + yield [' ', '', 2, ' ', \STR_PAD_BOTH]; + yield [' ', '', 1, ' ', \STR_PAD_BOTH]; + yield ['', '', 0, ' ', \STR_PAD_BOTH]; + yield ['', '', -1, ' ', \STR_PAD_BOTH]; + // Default argument + yield ['▶▶ ', '▶▶', 6, ' ', \STR_PAD_RIGHT]; + yield [' ▶▶', '▶▶', 6, ' ', \STR_PAD_LEFT]; + yield [' ▶▶ ', '▶▶', 6, ' ', \STR_PAD_BOTH]; + } + + public static function paddingEmojiProvider(): iterable + { + // UTF-8 Emojis + yield ['▶▶❤❓❇❤', '▶▶', 6, '❤❓❇', \STR_PAD_RIGHT]; + yield ['❤❓❇❤▶▶', '▶▶', 6, '❤❓❇', \STR_PAD_LEFT]; + yield ['❤❓▶▶❤❓', '▶▶', 6, '❤❓❇', \STR_PAD_BOTH]; + yield ['▶▶❤❓❇', '▶▶', 5, '❤❓❇', \STR_PAD_RIGHT]; + yield ['❤❓❇▶▶', '▶▶', 5, '❤❓❇', \STR_PAD_LEFT]; + yield ['❤▶▶❤❓', '▶▶', 5, '❤❓❇', \STR_PAD_BOTH]; + yield ['▶▶❤❓', '▶▶', 4, '❤❓❇', \STR_PAD_RIGHT]; + yield ['❤❓▶▶', '▶▶', 4, '❤❓❇', \STR_PAD_LEFT]; + yield ['❤▶▶❤', '▶▶', 4, '❤❓❇', \STR_PAD_BOTH]; + yield ['▶▶❤', '▶▶', 3, '❤❓❇', \STR_PAD_RIGHT]; + yield ['❤▶▶', '▶▶', 3, '❤❓❇', \STR_PAD_LEFT]; + yield ['▶▶❤', '▶▶', 3, '❤❓❇', \STR_PAD_BOTH]; + + for ($i = 2; $i >= 0; --$i) { + yield ['▶▶', '▶▶', $i, '❤❓❇', \STR_PAD_RIGHT]; + yield ['▶▶', '▶▶', $i, '❤❓❇', \STR_PAD_LEFT]; + yield ['▶▶', '▶▶', $i, '❤❓❇', \STR_PAD_BOTH]; + } + } + + public static function paddingEncodingProvider(): iterable + { + $string = 'Σὲ γνωρίζω ἀπὸ τὴν κόψη Зарегистрируйтесь'; + + foreach (['UTF-8', 'UTF-32', 'UTF-7'] as $encoding) { + $input = mb_convert_encoding($string, $encoding, 'UTF-8'); + $padStr = mb_convert_encoding('▶▶', $encoding, 'UTF-8'); + + yield ['Σὲ γνωρίζω ἀπὸ τὴν κόψη Зарегистрируйтесь▶▶▶', $input, 44, $padStr, \STR_PAD_RIGHT, $encoding]; + yield ['▶▶▶Σὲ γνωρίζω ἀπὸ τὴν κόψη Зарегистрируйтесь', $input, 44, $padStr, \STR_PAD_LEFT, $encoding]; + yield ['▶Σὲ γνωρίζω ἀπὸ τὴν κόψη Зарегистрируйтесь▶▶', $input, 44, $padStr, \STR_PAD_BOTH, $encoding]; + } + } + + public static function mbStrPadInvalidArgumentsProvider(): iterable + { + yield ['mb_str_pad(): Argument #3 ($pad_string) must be a non-empty string', '▶▶', 6, '', \STR_PAD_RIGHT]; + yield ['mb_str_pad(): Argument #3 ($pad_string) must be a non-empty string', '▶▶', 6, '', \STR_PAD_LEFT]; + yield ['mb_str_pad(): Argument #3 ($pad_string) must be a non-empty string', '▶▶', 6, '', \STR_PAD_BOTH]; + yield ['mb_str_pad(): Argument #4 ($pad_type) must be STR_PAD_LEFT, STR_PAD_RIGHT, or STR_PAD_BOTH', '▶▶', 6, ' ', 123456]; + yield ['mb_str_pad(): Argument #5 ($encoding) must be a valid encoding, "unexisting" given', '▶▶', 6, ' ', \STR_PAD_BOTH, 'unexisting']; + } } diff --git a/tests/Php83/Php83Test.php b/tests/Php83/Php83Test.php index a79574fa..b22f0d65 100644 --- a/tests/Php83/Php83Test.php +++ b/tests/Php83/Php83Test.php @@ -17,6 +17,7 @@ class Php83Test extends TestCase { /** * @covers \Symfony\Polyfill\Php83\Php83::json_validate + * * @dataProvider jsonDataProvider */ public function testJsonValidate(bool $valid, string $json, string $errorMessage = 'No error', int $depth = 512, int $options = 0) @@ -25,6 +26,102 @@ public function testJsonValidate(bool $valid, string $json, string $errorMessage $this->assertSame($errorMessage, json_last_error_msg()); } + /** + * @covers \Symfony\Polyfill\Php83\Php83::mb_str_pad + * + * @dataProvider paddingStringProvider + * @dataProvider paddingEmojiProvider + * @dataProvider paddingEncodingProvider + */ + public function testMbStrPad(string $expectedResult, string $string, int $length, string $padString, int $padType, string $encoding = null): void + { + $this->assertSame($expectedResult, mb_convert_encoding(mb_str_pad($string, $length, $padString, $padType, $encoding), 'UTF-8', $encoding ?? mb_internal_encoding())); + } + + /** + * @covers \Symfony\Polyfill\Php83\Php83::mb_str_pad + * + * @dataProvider mbStrPadInvalidArgumentsProvider + */ + public function testMbStrPadInvalidArguments(string $expectedError, string $string, int $length, string $padString, int $padType, string $encoding = null): void + { + $this->expectException(\ValueError::class); + $this->expectErrorMessage($expectedError); + + mb_str_pad($string, $length, $padString, $padType, $encoding); + } + + public static function paddingStringProvider(): iterable + { + // Simple ASCII strings + yield ['+Hello+', 'Hello', 7, '+-', \STR_PAD_BOTH]; + yield ['+-World+-+', 'World', 10, '+-', \STR_PAD_BOTH]; + yield ['+-Hello', 'Hello', 7, '+-', \STR_PAD_LEFT]; + yield ['+-+-+World', 'World', 10, '+-', \STR_PAD_LEFT]; + yield ['Hello+-', 'Hello', 7, '+-', \STR_PAD_RIGHT]; + yield ['World+-+-+', 'World', 10, '+-', \STR_PAD_RIGHT]; + // Edge cases pad length + yield ['▶▶', '▶▶', 2, ' ', \STR_PAD_BOTH]; + yield ['▶▶', '▶▶', 1, ' ', \STR_PAD_BOTH]; + yield ['▶▶', '▶▶', 0, ' ', \STR_PAD_BOTH]; + yield ['▶▶', '▶▶', -1, ' ', \STR_PAD_BOTH]; + // Empty input string + yield [' ', '', 2, ' ', \STR_PAD_BOTH]; + yield [' ', '', 1, ' ', \STR_PAD_BOTH]; + yield ['', '', 0, ' ', \STR_PAD_BOTH]; + yield ['', '', -1, ' ', \STR_PAD_BOTH]; + // Default argument + yield ['▶▶ ', '▶▶', 6, ' ', \STR_PAD_RIGHT]; + yield [' ▶▶', '▶▶', 6, ' ', \STR_PAD_LEFT]; + yield [' ▶▶ ', '▶▶', 6, ' ', \STR_PAD_BOTH]; + } + + public static function paddingEmojiProvider(): iterable + { + // UTF-8 Emojis + yield ['▶▶❤❓❇❤', '▶▶', 6, '❤❓❇', \STR_PAD_RIGHT]; + yield ['❤❓❇❤▶▶', '▶▶', 6, '❤❓❇', \STR_PAD_LEFT]; + yield ['❤❓▶▶❤❓', '▶▶', 6, '❤❓❇', \STR_PAD_BOTH]; + yield ['▶▶❤❓❇', '▶▶', 5, '❤❓❇', \STR_PAD_RIGHT]; + yield ['❤❓❇▶▶', '▶▶', 5, '❤❓❇', \STR_PAD_LEFT]; + yield ['❤▶▶❤❓', '▶▶', 5, '❤❓❇', \STR_PAD_BOTH]; + yield ['▶▶❤❓', '▶▶', 4, '❤❓❇', \STR_PAD_RIGHT]; + yield ['❤❓▶▶', '▶▶', 4, '❤❓❇', \STR_PAD_LEFT]; + yield ['❤▶▶❤', '▶▶', 4, '❤❓❇', \STR_PAD_BOTH]; + yield ['▶▶❤', '▶▶', 3, '❤❓❇', \STR_PAD_RIGHT]; + yield ['❤▶▶', '▶▶', 3, '❤❓❇', \STR_PAD_LEFT]; + yield ['▶▶❤', '▶▶', 3, '❤❓❇', \STR_PAD_BOTH]; + + for ($i = 2; $i >= 0; --$i) { + yield ['▶▶', '▶▶', $i, '❤❓❇', \STR_PAD_RIGHT]; + yield ['▶▶', '▶▶', $i, '❤❓❇', \STR_PAD_LEFT]; + yield ['▶▶', '▶▶', $i, '❤❓❇', \STR_PAD_BOTH]; + } + } + + public static function paddingEncodingProvider(): iterable + { + $string = 'Σὲ γνωρίζω ἀπὸ τὴν κόψη Зарегистрируйтесь'; + + foreach (['UTF-8', 'UTF-32', 'UTF-7'] as $encoding) { + $input = mb_convert_encoding($string, $encoding, 'UTF-8'); + $padStr = mb_convert_encoding('▶▶', $encoding, 'UTF-8'); + + yield ['Σὲ γνωρίζω ἀπὸ τὴν κόψη Зарегистрируйтесь▶▶▶', $input, 44, $padStr, \STR_PAD_RIGHT, $encoding]; + yield ['▶▶▶Σὲ γνωρίζω ἀπὸ τὴν κόψη Зарегистрируйтесь', $input, 44, $padStr, \STR_PAD_LEFT, $encoding]; + yield ['▶Σὲ γνωρίζω ἀπὸ τὴν κόψη Зарегистрируйтесь▶▶', $input, 44, $padStr, \STR_PAD_BOTH, $encoding]; + } + } + + public static function mbStrPadInvalidArgumentsProvider(): iterable + { + yield ['mb_str_pad(): Argument #3 ($pad_string) must be a non-empty string', '▶▶', 6, '', \STR_PAD_RIGHT]; + yield ['mb_str_pad(): Argument #3 ($pad_string) must be a non-empty string', '▶▶', 6, '', \STR_PAD_LEFT]; + yield ['mb_str_pad(): Argument #3 ($pad_string) must be a non-empty string', '▶▶', 6, '', \STR_PAD_BOTH]; + yield ['mb_str_pad(): Argument #4 ($pad_type) must be STR_PAD_LEFT, STR_PAD_RIGHT, or STR_PAD_BOTH', '▶▶', 6, ' ', 123456]; + yield ['mb_str_pad(): Argument #5 ($encoding) must be a valid encoding, "unexisting" given', '▶▶', 6, ' ', \STR_PAD_BOTH, 'unexisting']; + } + /** * @return iterable */ @@ -59,9 +156,9 @@ public static function jsonDataProvider(): iterable /** * @covers \Symfony\Polyfill\Php83\Php83::json_validate * - * @dataProvider invalidOptionsProvider + * @dataProvider jsonInvalidOptionsProvider */ - public function testInvalidOptionsProvided(int $depth, int $flags, string $expectedError) + public function testJsonValidateInvalidOptionsProvided(int $depth, int $flags, string $expectedError) { $this->expectException(\ValueError::class); $this->expectErrorMessage($expectedError); @@ -71,7 +168,7 @@ public function testInvalidOptionsProvided(int $depth, int $flags, string $expec /** * @return iterable */ - public static function invalidOptionsProvider(): iterable + public static function jsonInvalidOptionsProvider(): iterable { yield [0, 0, 'json_validate(): Argument #2 ($depth) must be greater than 0']; if (\PHP_INT_MAX > 2147483647) {