diff --git a/NEWS b/NEWS index 86cb13963862..2f94134f6cd7 100644 --- a/NEWS +++ b/NEWS @@ -28,6 +28,7 @@ PHP NEWS (Weilin Du) . Added Transliterator::toRules() and transliterator_to_rules(). (Weilin Du, David Carlier) + . Added grapheme_str_pad(). (Chris Abner Herrera Navarro) . Fixed Collator attribute and strength methods not rejecting an unconstructed Collator. (Ilia Alshanetsky) . Added optional $availableLocales argument to Locale::acceptFromHttp() diff --git a/UPGRADING b/UPGRADING index dbfa42b9b171..9aaf90c50a9c 100644 --- a/UPGRADING +++ b/UPGRADING @@ -116,6 +116,9 @@ PHP 8.7 UPGRADE NOTES . Added Transliterator::toRules() and transliterator_to_rules() to export rules that can be used to recreate a transliterator. The optional $escapeUnprintable argument escapes unprintable characters when true. + . Added grapheme_str_pad() to pad a string to a length counted in grapheme + clusters, the grapheme counterpart of str_pad() and mb_str_pad(). + RFC: https://wiki.php.net/rfc/grapheme_str_pad ======================================== 7. New Classes and Interfaces diff --git a/ext/intl/grapheme/grapheme_string.cpp b/ext/intl/grapheme/grapheme_string.cpp index 4acf636d54b1..0d6370033ee1 100644 --- a/ext/intl/grapheme/grapheme_string.cpp +++ b/ext/intl/grapheme/grapheme_string.cpp @@ -23,6 +23,7 @@ #endif #include +#include "ext/standard/php_string.h" #include "grapheme.h" #include "grapheme_util.h" @@ -1151,6 +1152,222 @@ U_CFUNC PHP_FUNCTION(grapheme_levenshtein) efree(ustring1); } +/* {{{ Returns the byte length, in the UTF-8 string str, of the first upos UTF-16 code units of ustr, + * its UTF-16 conversion. */ +static size_t grapheme_str_pad_utf8_offset(const zend_string *str, const UChar *ustr, int32_t upos) +{ + int32_t code_points = u_countChar32(ustr, upos); + int32_t offset = 0; + U8_FWD_N(ZSTR_VAL(str), offset, (int32_t) ZSTR_LEN(str), code_points); + return offset; +} +/* }}} */ + +/* {{{ Counts the grapheme clusters of a UTF-8 string, but stops once limit clusters have been counted. + * Also stores the number of bytes taken by its first prefix1 and prefix2 clusters in bytes1 and bytes2, + * if the string has that many (0 otherwise). Returns false on failure. */ +static bool grapheme_str_pad_measure(const zend_string *str, size_t limit, size_t *count, + size_t prefix1, size_t *bytes1, size_t prefix2, size_t *bytes2) +{ + if (grapheme_ascii_check((const unsigned char *) ZSTR_VAL(str), ZSTR_LEN(str)) >= 0) { + *count = ZSTR_LEN(str); + *bytes1 = prefix1 <= ZSTR_LEN(str) ? prefix1 : 0; + *bytes2 = prefix2 <= ZSTR_LEN(str) ? prefix2 : 0; + return true; + } + + if (UNEXPECTED(ZSTR_LEN(str) > INT32_MAX)) { + intl_error_set_code(nullptr, U_INDEX_OUTOFBOUNDS_ERROR); + intl_error_set_custom_msg(nullptr, "String is too long"); + return false; + } + + /* Like grapheme_strlen(), work on UTF-16. The conversion also rejects invalid UTF-8. */ + UErrorCode ustatus = U_ZERO_ERROR; + UChar *ustr = nullptr; + int32_t ulen = 0; + intl_convert_utf8_to_utf16(&ustr, &ulen, ZSTR_VAL(str), ZSTR_LEN(str), &ustatus); + + if (U_FAILURE(ustatus)) { + intl_error_set_code(nullptr, ustatus); + intl_error_set_custom_msg(nullptr, "Invalid UTF-8 string"); + if (ustr) { + efree(ustr); + } + return false; + } + + UBreakIterator *bi = grapheme_get_break_iterator(&ustatus); + + if (U_FAILURE(ustatus)) { + intl_error_set_code(nullptr, ustatus); + intl_error_set_custom_msg(nullptr, "Error creating grapheme break iterator"); + efree(ustr); + return false; + } + + ubrk_setText(bi, ustr, ulen, &ustatus); + + if (U_FAILURE(ustatus)) { + intl_error_set_code(nullptr, ustatus); + intl_error_set_custom_msg(nullptr, "Error setting up grapheme break iterator"); + ubrk_close(bi); + efree(ustr); + return false; + } + + size_t graphemes = 0; + int32_t upos1 = 0, upos2 = 0; + + for (int32_t pos = ubrk_next(bi); pos != UBRK_DONE && graphemes < limit; pos = ubrk_next(bi)) { + graphemes++; + if (graphemes == prefix1) { + upos1 = pos; + } + if (graphemes == prefix2) { + upos2 = pos; + } + } + + ubrk_close(bi); + + *bytes1 = upos1 ? grapheme_str_pad_utf8_offset(str, ustr, upos1) : 0; + *bytes2 = upos2 ? grapheme_str_pad_utf8_offset(str, ustr, upos2) : 0; + *count = graphemes; + efree(ustr); + return true; +} +/* }}} */ + +/* {{{ Writes full_copies copies of the padding string followed by its first remaining_bytes bytes. */ +static char *grapheme_str_pad_fill(char *buffer, const zend_string *pad, size_t full_copies, size_t remaining_bytes) +{ + if (full_copies > 0) { + size_t full_bytes = full_copies * ZSTR_LEN(pad); + if (ZSTR_LEN(pad) == 1) { + memset(buffer, ZSTR_VAL(pad)[0], full_bytes); + buffer += full_bytes; + } else { + const char *start = buffer; + const char *end = buffer + full_bytes; + buffer = (char *) zend_mempcpy(buffer, ZSTR_VAL(pad), ZSTR_LEN(pad)); + + /* Double the filled area on each iteration, like mb_str_pad(). */ + while (buffer < end) { + size_t len = MIN((size_t) (buffer - start), (size_t) (end - buffer)); + buffer = (char *) zend_mempcpy(buffer, start, len); + } + } + } + return (char *) zend_mempcpy(buffer, ZSTR_VAL(pad), remaining_bytes); +} +/* }}} */ + +/* {{{ Pad a string to a certain length, counted in grapheme clusters, with another string */ +U_CFUNC PHP_FUNCTION(grapheme_str_pad) +{ + zend_string *input, *pad = ZSTR_CHAR(' '); + zend_long pad_to_length; + zend_long pad_type_val = PHP_STR_PAD_RIGHT; + + ZEND_PARSE_PARAMETERS_START(2, 4) + Z_PARAM_STR(input) + Z_PARAM_LONG(pad_to_length) + Z_PARAM_OPTIONAL + Z_PARAM_STR(pad) + Z_PARAM_LONG(pad_type_val) + ZEND_PARSE_PARAMETERS_END(); + + intl_error_reset(nullptr); + + /* Counting stops past pad_to_length: a longer input is returned unchanged anyway. */ + size_t input_length, unused; + if (!grapheme_str_pad_measure(input, pad_to_length < 0 ? 0 : (size_t) pad_to_length + 1, &input_length, + 0, &unused, 0, &unused)) { + RETURN_FALSE; + } + + if (pad_to_length < 0 || (size_t) pad_to_length <= input_length) { + RETURN_STR_COPY(input); + } + + if (ZSTR_LEN(pad) == 0) { + zend_argument_must_not_be_empty_error(3); + RETURN_THROWS(); + } + + if (pad_type_val < PHP_STR_PAD_LEFT || pad_type_val > PHP_STR_PAD_BOTH) { + zend_argument_value_error(4, "must be STR_PAD_LEFT, STR_PAD_RIGHT, or STR_PAD_BOTH"); + RETURN_THROWS(); + } + + size_t num_pad_graphemes = (size_t) pad_to_length - input_length; + size_t left_pad = 0, right_pad = 0; + switch (pad_type_val) { + case PHP_STR_PAD_RIGHT: + right_pad = num_pad_graphemes; + break; + case PHP_STR_PAD_LEFT: + left_pad = num_pad_graphemes; + break; + case PHP_STR_PAD_BOTH: + left_pad = num_pad_graphemes / 2; + right_pad = num_pad_graphemes - left_pad; + break; + } + + /* Only the first MAX(left_pad, right_pad) clusters of the padding string can be needed. If it has + * that many, a prefix of it is enough on each side; otherwise it is repeated. */ + size_t pad_length, left_remaining_bytes, right_remaining_bytes; + size_t full_left_copies = 0, full_right_copies = 0; + if (!grapheme_str_pad_measure(pad, MAX(left_pad, right_pad), &pad_length, + left_pad, &left_remaining_bytes, right_pad, &right_remaining_bytes)) { + RETURN_FALSE; + } + if (pad_length < MAX(left_pad, right_pad)) { + full_left_copies = left_pad / pad_length; + full_right_copies = right_pad / pad_length; + if (!grapheme_str_pad_measure(pad, pad_length, &unused, + left_pad % pad_length, &left_remaining_bytes, right_pad % pad_length, &right_remaining_bytes)) { + RETURN_FALSE; + } + } + + if (UNEXPECTED(full_left_copies > (ZSTR_MAX_LEN - left_remaining_bytes) / ZSTR_LEN(pad) + || full_right_copies > (ZSTR_MAX_LEN - right_remaining_bytes) / ZSTR_LEN(pad))) { + goto overflow; + } + + { + size_t left_bytes = full_left_copies * ZSTR_LEN(pad) + left_remaining_bytes; + size_t right_bytes = full_right_copies * ZSTR_LEN(pad) + right_remaining_bytes; + + if (UNEXPECTED(left_bytes > ZSTR_MAX_LEN - right_bytes + || ZSTR_LEN(input) > ZSTR_MAX_LEN - left_bytes - right_bytes)) { + goto overflow; + } + + zend_string *result = zend_string_safe_falloc(1, ZSTR_LEN(input), left_bytes + right_bytes, false, /* fallible */ true); + if (UNEXPECTED(!result)) { + RETURN_OOM(); + } + char *buffer = ZSTR_VAL(result); + + buffer = grapheme_str_pad_fill(buffer, pad, full_left_copies, left_remaining_bytes); + memcpy(buffer, ZSTR_VAL(input), ZSTR_LEN(input)); + buffer += ZSTR_LEN(input); + buffer = grapheme_str_pad_fill(buffer, pad, full_right_copies, right_remaining_bytes); + *buffer = '\0'; + + RETURN_NEW_STR(result); + } + +overflow: + zend_throw_error(nullptr, "String size overflow"); + RETURN_THROWS(); +} +/* }}} */ + U_CFUNC PHP_FUNCTION(grapheme_strrev) { zend_string *string; diff --git a/ext/intl/php_intl.stub.php b/ext/intl/php_intl.stub.php index a6a519e50f65..2fbfd5f90e25 100644 --- a/ext/intl/php_intl.stub.php +++ b/ext/intl/php_intl.stub.php @@ -447,6 +447,8 @@ function grapheme_levenshtein(string $string1, string $string2, int $insertion_c function grapheme_strrev(string $string): string|false {} +function grapheme_str_pad(string $string, int $length, string $pad_string = " ", int $pad_type = STR_PAD_RIGHT): string|false {} + /** @param int $next */ function grapheme_extract(string $haystack, int $size, int $type = GRAPHEME_EXTR_COUNT, int $offset = 0, &$next = null): string|false {} diff --git a/ext/intl/php_intl_arginfo.h b/ext/intl/php_intl_arginfo.h index a1b95671d275..46c1a500c566 100644 --- a/ext/intl/php_intl_arginfo.h +++ b/ext/intl/php_intl_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit php_intl.stub.php instead. - * Stub hash: c54e9724edabab5a6531e535f2226f512a904c8e */ + * Stub hash: ed75eb7fd8b3b113d08e8c2a02ff3050f1f01132 */ #include "zend_attributes.h" #include "zend_constants.h" @@ -508,6 +508,13 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_MASK_EX(arginfo_grapheme_strrev, 0, 1, MAY_BE_ST ZEND_ARG_TYPE_INFO(0, string, IS_STRING, 0) ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_MASK_EX(arginfo_grapheme_str_pad, 0, 2, MAY_BE_STRING|MAY_BE_FALSE) + ZEND_ARG_TYPE_INFO(0, string, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, length, IS_LONG, 0) + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, pad_string, IS_STRING, 0, "\" \"") + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, pad_type, IS_LONG, 0, "STR_PAD_RIGHT") +ZEND_END_ARG_INFO() + ZEND_BEGIN_ARG_WITH_RETURN_TYPE_MASK_EX(arginfo_grapheme_extract, 0, 2, MAY_BE_STRING|MAY_BE_FALSE) ZEND_ARG_TYPE_INFO(0, haystack, IS_STRING, 0) ZEND_ARG_TYPE_INFO(0, size, IS_LONG, 0) @@ -947,6 +954,7 @@ ZEND_FUNCTION(grapheme_stristr); ZEND_FUNCTION(grapheme_str_split); ZEND_FUNCTION(grapheme_levenshtein); ZEND_FUNCTION(grapheme_strrev); +ZEND_FUNCTION(grapheme_str_pad); ZEND_FUNCTION(grapheme_extract); ZEND_FUNCTION(idn_to_ascii); ZEND_FUNCTION(idn_to_utf8); @@ -1142,6 +1150,7 @@ static const zend_function_entry ext_functions[] = { ZEND_FE(grapheme_str_split, arginfo_grapheme_str_split) ZEND_FE(grapheme_levenshtein, arginfo_grapheme_levenshtein) ZEND_FE(grapheme_strrev, arginfo_grapheme_strrev) + ZEND_FE(grapheme_str_pad, arginfo_grapheme_str_pad) ZEND_FE(grapheme_extract, arginfo_grapheme_extract) ZEND_FE(idn_to_ascii, arginfo_idn_to_ascii) ZEND_FE(idn_to_utf8, arginfo_idn_to_utf8) diff --git a/ext/intl/tests/grapheme_str_pad_basic.phpt b/ext/intl/tests/grapheme_str_pad_basic.phpt new file mode 100644 index 000000000000..1e9c3ce47dfd --- /dev/null +++ b/ext/intl/tests/grapheme_str_pad_basic.phpt @@ -0,0 +1,65 @@ +--TEST-- +grapheme_str_pad() pads to a length counted in grapheme clusters +--EXTENSIONS-- +intl +--FILE-- + +--EXPECT-- +bool(true) +bool(true) +bool(true) +string(9) "Müller.." +bool(true) +bool(true) +int(5) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +string(3) "abc" +string(3) "abc" +bool(true) +bool(true) +string(3) " " diff --git a/ext/intl/tests/grapheme_str_pad_errors.phpt b/ext/intl/tests/grapheme_str_pad_errors.phpt new file mode 100644 index 000000000000..6e9d22e54281 --- /dev/null +++ b/ext/intl/tests/grapheme_str_pad_errors.phpt @@ -0,0 +1,47 @@ +--TEST-- +grapheme_str_pad() error conditions +--EXTENSIONS-- +intl +--FILE-- +getMessage(), PHP_EOL; +} + +try { + grapheme_str_pad("abc", 5, "-", 3); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} + +// No error when there is nothing to pad, like mb_str_pad(): the padding string and the pad type are not checked +var_dump(grapheme_str_pad("abc", 2, "")); +var_dump(grapheme_str_pad("abc", 2, "\xff")); +var_dump(grapheme_str_pad("abc", 3, "-", 3)); + +// Invalid UTF-8 in the input or in the padding string +var_dump(grapheme_str_pad("a\xffb", 5, ".")); +var_dump(intl_get_error_message()); +var_dump(grapheme_str_pad("ab\u{00FC}", 5, "\xff")); + +// The input is always checked, even when no padding is needed +var_dump(grapheme_str_pad("a\xffb", 1, ".")); + +// A successful call clears the error of a previous one +var_dump(grapheme_str_pad("ab\u{00FC}", 5, ".")); +var_dump(intl_get_error_code()); +?> +--EXPECT-- +ValueError: grapheme_str_pad(): Argument #3 ($pad_string) must not be empty +ValueError: grapheme_str_pad(): Argument #4 ($pad_type) must be STR_PAD_LEFT, STR_PAD_RIGHT, or STR_PAD_BOTH +string(3) "abc" +string(3) "abc" +string(3) "abc" +bool(false) +string(62) "grapheme_str_pad(): Invalid UTF-8 string: U_INVALID_CHAR_FOUND" +bool(false) +bool(false) +string(6) "abü.." +int(0) diff --git a/ext/intl/tests/grapheme_str_pad_merge.phpt b/ext/intl/tests/grapheme_str_pad_merge.phpt new file mode 100644 index 000000000000..6968cfd2d36a --- /dev/null +++ b/ext/intl/tests/grapheme_str_pad_merge.phpt @@ -0,0 +1,32 @@ +--TEST-- +grapheme_str_pad() with padding or input fragments that merge at a join +--EXTENSIONS-- +intl +--FILE-- + ["e", 3, "\u{0301}", STR_PAD_RIGHT, "e\u{0301}\u{0301}"], + 'padding starting with U+200D ZWJ' => ["\u{1F468}", 3, "\u{200D}", STR_PAD_RIGHT, "\u{1F468}\u{200D}\u{200D}"], + 'lone regional indicator' => ["", 2, "\u{1F1EA}", STR_PAD_RIGHT, "\u{1F1EA}\u{1F1EA}"], + 'Hangul conjoining jamo' => ["\u{1100}", 3, "\u{1161}", STR_PAD_RIGHT, "\u{1100}\u{1161}\u{1161}"], + 'string starting with a combining mark' => ["\u{0301}x", 4, "a", STR_PAD_LEFT, "aa\u{0301}x"], + '"\r" padding before a "\n"' => ["\nx", 4, "\r", STR_PAD_LEFT, "\r\r\nx"], + 'string ending with "\r" before "\n" padding' => ["x\r", 4, "\n", STR_PAD_RIGHT, "x\r\n\n"], +]; + +foreach ($cases as $name => [$string, $length, $pad, $type, $expected]) { + $result = grapheme_str_pad($string, $length, $pad, $type); + echo $name, ': ', var_export($result === $expected, true), ', ', grapheme_strlen($result), ' of ', $length, PHP_EOL; +} +?> +--EXPECT-- +padding starting with a combining mark: true, 1 of 3 +padding starting with U+200D ZWJ: true, 1 of 3 +lone regional indicator: true, 1 of 2 +Hangul conjoining jamo: true, 1 of 3 +string starting with a combining mark: true, 3 of 4 +"\r" padding before a "\n": true, 3 of 4 +string ending with "\r" before "\n" padding: true, 3 of 4 diff --git a/ext/intl/tests/grapheme_str_pad_oom.phpt b/ext/intl/tests/grapheme_str_pad_oom.phpt new file mode 100644 index 000000000000..ac3adf436356 --- /dev/null +++ b/ext/intl/tests/grapheme_str_pad_oom.phpt @@ -0,0 +1,14 @@ +--TEST-- +grapheme_str_pad() respects memory_limit when the result is too large +--EXTENSIONS-- +intl +--INI-- +memory_limit=128M +--FILE-- + +--EXPECTF-- +Fatal error: Allowed memory size of 134217728 bytes exhausted %S(tried to allocate %d bytes) in %s on line %d diff --git a/ext/intl/tests/grapheme_str_pad_overflow.phpt b/ext/intl/tests/grapheme_str_pad_overflow.phpt new file mode 100644 index 000000000000..3337634e6163 --- /dev/null +++ b/ext/intl/tests/grapheme_str_pad_overflow.phpt @@ -0,0 +1,25 @@ +--TEST-- +grapheme_str_pad() throws when the result size overflows, like mb_str_pad() +--EXTENSIONS-- +intl +--SKIPIF-- + +--FILE-- +getMessage(), PHP_EOL; +} + +// Each side fits, both together do not +try { + grapheme_str_pad("a", PHP_INT_MAX, "\u{00E9}", STR_PAD_BOTH); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} +?> +--EXPECT-- +Error: String size overflow +Error: String size overflow