From b7477918583454c81c2ee8c371a3d3d0e0ccf501 Mon Sep 17 00:00:00 2001 From: ChrisAbner Date: Tue, 3 Nov 2026 17:51:25 -0500 Subject: [PATCH 01/11] Add grapheme_str_pad() Like mb_str_pad(), but the target length is counted in grapheme clusters, so NFC and NFD forms of the same text, combining marks and emoji sequences are padded to the same visible length. The padding string is only cut on grapheme boundaries. Invalid UTF-8 makes it return false, like grapheme_strlen(). RFC: https://wiki.php.net/rfc/grapheme_str_pad --- NEWS | 1 + UPGRADING | 3 + ext/intl/grapheme/grapheme_string.cpp | 175 ++++++++++++++++++++ ext/intl/php_intl.stub.php | 2 + ext/intl/php_intl_arginfo.h | 11 +- ext/intl/tests/grapheme_str_pad_basic.phpt | 51 ++++++ ext/intl/tests/grapheme_str_pad_errors.phpt | 33 ++++ 7 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 ext/intl/tests/grapheme_str_pad_basic.phpt create mode 100644 ext/intl/tests/grapheme_str_pad_errors.phpt 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..94799e174892 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,180 @@ U_CFUNC PHP_FUNCTION(grapheme_levenshtein) efree(ustring1); } +/* {{{ Counts the grapheme clusters of a UTF-8 string and, if prefix_bytes is not NULL, the number of bytes + * taken by its first prefix_graphemes clusters. Returns false on failure. */ +static bool grapheme_str_pad_measure(const zend_string *str, size_t prefix_graphemes, size_t *count, size_t *prefix_bytes) +{ + if (grapheme_ascii_check((const unsigned char *) ZSTR_VAL(str), ZSTR_LEN(str)) >= 0) { + *count = ZSTR_LEN(str); + if (prefix_bytes != nullptr) { + *prefix_bytes = MIN(prefix_graphemes, ZSTR_LEN(str)); + } + 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; + } + + /* Reject invalid UTF-8, like grapheme_strlen() does, instead of counting each invalid byte. */ + for (int32_t i = 0, len = (int32_t) ZSTR_LEN(str); i < len;) { + UChar32 c; + U8_NEXT(ZSTR_VAL(str), i, len, c); + if (c < 0) { + intl_error_set_code(nullptr, U_INVALID_CHAR_FOUND); + intl_error_set_custom_msg(nullptr, "Invalid UTF-8 string"); + return false; + } + } + + UErrorCode ustatus = U_ZERO_ERROR; + UText *ut = utext_openUTF8(nullptr, ZSTR_VAL(str), ZSTR_LEN(str), &ustatus); + + if (U_FAILURE(ustatus)) { + intl_error_set_code(nullptr, ustatus); + intl_error_set_custom_msg(nullptr, "Error opening UTF-8 text"); + 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"); + utext_close(ut); + return false; + } + + ubrk_setUText(bi, ut, &ustatus); + + size_t graphemes = 0; + if (prefix_bytes != nullptr) { + *prefix_bytes = 0; + } + + for (int32_t pos = ubrk_next(bi); pos != UBRK_DONE; pos = ubrk_next(bi)) { + graphemes++; + if (prefix_bytes != nullptr && graphemes == prefix_graphemes) { + *prefix_bytes = pos; + } + } + + ubrk_close(bi); + utext_close(ut); + + *count = graphemes; + 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) +{ + for (size_t i = 0; i < full_copies; i++) { + memcpy(buffer, ZSTR_VAL(pad), ZSTR_LEN(pad)); + buffer += ZSTR_LEN(pad); + } + memcpy(buffer, ZSTR_VAL(pad), remaining_bytes); + return buffer + 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(); + + size_t input_length; + if (!grapheme_str_pad_measure(input, 0, &input_length, nullptr)) { + 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; + } + + size_t pad_length, left_remaining_bytes, right_remaining_bytes, unused; + if (!grapheme_str_pad_measure(pad, 0, &pad_length, nullptr)) { + RETURN_FALSE; + } + if (!grapheme_str_pad_measure(pad, left_pad % pad_length, &unused, &left_remaining_bytes) + || !grapheme_str_pad_measure(pad, right_pad % pad_length, &unused, &right_remaining_bytes)) { + RETURN_FALSE; + } + + size_t full_left_copies = left_pad / pad_length; + size_t full_right_copies = right_pad / pad_length; + + 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_alloc(1, ZSTR_LEN(input), left_bytes + right_bytes, false); + 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..dd91d1d680aa --- /dev/null +++ b/ext/intl/tests/grapheme_str_pad_basic.phpt @@ -0,0 +1,51 @@ +--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) +string(3) "abc" +string(3) "abc" +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..f79c3773ae3c --- /dev/null +++ b/ext/intl/tests/grapheme_str_pad_errors.phpt @@ -0,0 +1,33 @@ +--TEST-- +grapheme_str_pad() error conditions +--EXTENSIONS-- +intl +--FILE-- +getMessage(), PHP_EOL; +} + +try { + grapheme_str_pad("abc", 5, "-", 3); +} catch (ValueError $e) { + echo $e->getMessage(), PHP_EOL; +} + +// No error when there is nothing to pad, like mb_str_pad() +var_dump(grapheme_str_pad("abc", 2, "")); + +// 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")); +?> +--EXPECT-- +grapheme_str_pad(): Argument #3 ($pad_string) must not be empty +grapheme_str_pad(): Argument #4 ($pad_type) must be STR_PAD_LEFT, STR_PAD_RIGHT, or STR_PAD_BOTH +string(3) "abc" +bool(false) +string(62) "grapheme_str_pad(): Invalid UTF-8 string: U_INVALID_CHAR_FOUND" +bool(false) From f397ad9d797b7a74fae4747e4f3b28c6cff7d908 Mon Sep 17 00:00:00 2001 From: ChrisAbner Date: Wed, 4 Nov 2026 10:46:10 -0500 Subject: [PATCH 02/11] Use fallible allocation in grapheme_str_pad() Like str_pad() and mb_str_pad(), an oversized result now hits the regular memory_limit fatal error instead of the hard memory limit. --- ext/intl/grapheme/grapheme_string.cpp | 5 ++++- ext/intl/tests/grapheme_str_pad_oom.phpt | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 ext/intl/tests/grapheme_str_pad_oom.phpt diff --git a/ext/intl/grapheme/grapheme_string.cpp b/ext/intl/grapheme/grapheme_string.cpp index 94799e174892..70a7e00ed941 100644 --- a/ext/intl/grapheme/grapheme_string.cpp +++ b/ext/intl/grapheme/grapheme_string.cpp @@ -1308,7 +1308,10 @@ U_CFUNC PHP_FUNCTION(grapheme_str_pad) goto overflow; } - zend_string *result = zend_string_safe_alloc(1, ZSTR_LEN(input), left_bytes + right_bytes, false); + 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); 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 From 47910378258573bd6d8325a0f1717ddc247d3148 Mon Sep 17 00:00:00 2001 From: ChrisAbner Date: Wed, 4 Nov 2026 10:56:20 -0500 Subject: [PATCH 03/11] Speed up grapheme_str_pad() Fill the padding like mb_str_pad() does, with memset() for a single byte and by doubling the copied area otherwise, instead of one memcpy() per copy of the padding string. Measure the padding string in one pass and stop once enough grapheme clusters are counted, so a long padding string is not walked three times. Stop counting the input once it is longer than the target length, since it is then returned unchanged. --- ext/intl/grapheme/grapheme_string.cpp | 76 ++++++++++++++-------- ext/intl/tests/grapheme_str_pad_basic.phpt | 14 ++++ 2 files changed, 63 insertions(+), 27 deletions(-) diff --git a/ext/intl/grapheme/grapheme_string.cpp b/ext/intl/grapheme/grapheme_string.cpp index 70a7e00ed941..d77c24c69c5e 100644 --- a/ext/intl/grapheme/grapheme_string.cpp +++ b/ext/intl/grapheme/grapheme_string.cpp @@ -1152,15 +1152,16 @@ U_CFUNC PHP_FUNCTION(grapheme_levenshtein) efree(ustring1); } -/* {{{ Counts the grapheme clusters of a UTF-8 string and, if prefix_bytes is not NULL, the number of bytes - * taken by its first prefix_graphemes clusters. Returns false on failure. */ -static bool grapheme_str_pad_measure(const zend_string *str, size_t prefix_graphemes, size_t *count, size_t *prefix_bytes) +/* {{{ 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); - if (prefix_bytes != nullptr) { - *prefix_bytes = MIN(prefix_graphemes, ZSTR_LEN(str)); - } + *bytes1 = prefix1 <= ZSTR_LEN(str) ? prefix1 : 0; + *bytes2 = prefix2 <= ZSTR_LEN(str) ? prefix2 : 0; return true; } @@ -1202,14 +1203,16 @@ static bool grapheme_str_pad_measure(const zend_string *str, size_t prefix_graph ubrk_setUText(bi, ut, &ustatus); size_t graphemes = 0; - if (prefix_bytes != nullptr) { - *prefix_bytes = 0; - } + *bytes1 = 0; + *bytes2 = 0; - for (int32_t pos = ubrk_next(bi); pos != UBRK_DONE; pos = ubrk_next(bi)) { + for (int32_t pos = ubrk_next(bi); pos != UBRK_DONE && graphemes < limit; pos = ubrk_next(bi)) { graphemes++; - if (prefix_bytes != nullptr && graphemes == prefix_graphemes) { - *prefix_bytes = pos; + if (graphemes == prefix1) { + *bytes1 = pos; + } + if (graphemes == prefix2) { + *bytes2 = pos; } } @@ -1224,12 +1227,24 @@ static bool grapheme_str_pad_measure(const zend_string *str, size_t prefix_graph /* {{{ 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) { - for (size_t i = 0; i < full_copies; i++) { - memcpy(buffer, ZSTR_VAL(pad), ZSTR_LEN(pad)); - buffer += ZSTR_LEN(pad); + 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); + } + } } - memcpy(buffer, ZSTR_VAL(pad), remaining_bytes); - return buffer + remaining_bytes; + return (char *) zend_mempcpy(buffer, ZSTR_VAL(pad), remaining_bytes); } /* }}} */ @@ -1248,8 +1263,10 @@ U_CFUNC PHP_FUNCTION(grapheme_str_pad) Z_PARAM_LONG(pad_type_val) ZEND_PARSE_PARAMETERS_END(); - size_t input_length; - if (!grapheme_str_pad_measure(input, 0, &input_length, 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; } @@ -1282,18 +1299,23 @@ U_CFUNC PHP_FUNCTION(grapheme_str_pad) break; } - size_t pad_length, left_remaining_bytes, right_remaining_bytes, unused; - if (!grapheme_str_pad_measure(pad, 0, &pad_length, nullptr)) { + /* 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 (!grapheme_str_pad_measure(pad, left_pad % pad_length, &unused, &left_remaining_bytes) - || !grapheme_str_pad_measure(pad, right_pad % pad_length, &unused, &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; + } } - size_t full_left_copies = left_pad / pad_length; - size_t full_right_copies = right_pad / pad_length; - 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; diff --git a/ext/intl/tests/grapheme_str_pad_basic.phpt b/ext/intl/tests/grapheme_str_pad_basic.phpt index dd91d1d680aa..1e9c3ce47dfd 100644 --- a/ext/intl/tests/grapheme_str_pad_basic.phpt +++ b/ext/intl/tests/grapheme_str_pad_basic.phpt @@ -28,9 +28,18 @@ var_dump(grapheme_str_pad("ab", 5, "\u{304B}\u{3099}x", STR_PAD_BOTH) === "\u{30 // "\r\n" is a single grapheme var_dump(grapheme_str_pad("\r\n", 2, "-") === "\r\n-"); +// Only a prefix of a long padding string is used +var_dump(grapheme_str_pad("ab", 5, "\u{304B}\u{3099}xyz", STR_PAD_BOTH) === "\u{304B}\u{3099}ab\u{304B}\u{3099}x"); + +// Many copies of the padding string, with a partial copy at the end +var_dump(grapheme_str_pad("x", 1001, "e\u{0301}ab", STR_PAD_LEFT) === str_repeat("e\u{0301}ab", 333) . "e\u{0301}x"); +var_dump(grapheme_str_pad("x", 1002, "\u{00B7}", STR_PAD_BOTH) === str_repeat("\u{00B7}", 500) . "x" . str_repeat("\u{00B7}", 501)); + // Nothing to pad var_dump(grapheme_str_pad("abc", 3, "-")); var_dump(grapheme_str_pad("abc", -1, "-")); +var_dump(grapheme_str_pad($nfd, 6, ".") === $nfd); +var_dump(grapheme_str_pad($nfd, 2, ".") === $nfd); var_dump(grapheme_str_pad("", 3)); ?> --EXPECT-- @@ -46,6 +55,11 @@ 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) " " From 829161eb15ca94ae9f10029a89f6f985b9c3a3b6 Mon Sep 17 00:00:00 2001 From: ChrisAbner Date: Wed, 4 Nov 2026 16:09:54 -0500 Subject: [PATCH 04/11] Test that grapheme_str_pad() skips argument checks when no padding is needed --- ext/intl/tests/grapheme_str_pad_errors.phpt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ext/intl/tests/grapheme_str_pad_errors.phpt b/ext/intl/tests/grapheme_str_pad_errors.phpt index f79c3773ae3c..4210eb203293 100644 --- a/ext/intl/tests/grapheme_str_pad_errors.phpt +++ b/ext/intl/tests/grapheme_str_pad_errors.phpt @@ -16,8 +16,10 @@ try { echo $e->getMessage(), PHP_EOL; } -// No error when there is nothing to pad, like mb_str_pad() +// 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, ".")); @@ -28,6 +30,8 @@ var_dump(grapheme_str_pad("ab\u{00FC}", 5, "\xff")); grapheme_str_pad(): Argument #3 ($pad_string) must not be empty 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) From 333837bb17b40568d3e6ad8fa8a7cbc840b83fd1 Mon Sep 17 00:00:00 2001 From: ChrisAbner Date: Wed, 4 Nov 2026 22:27:20 -0500 Subject: [PATCH 05/11] Check the status of ubrk_setUText() in grapheme_str_pad() --- ext/intl/grapheme/grapheme_string.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ext/intl/grapheme/grapheme_string.cpp b/ext/intl/grapheme/grapheme_string.cpp index d77c24c69c5e..502c3461e311 100644 --- a/ext/intl/grapheme/grapheme_string.cpp +++ b/ext/intl/grapheme/grapheme_string.cpp @@ -1202,6 +1202,14 @@ static bool grapheme_str_pad_measure(const zend_string *str, size_t limit, size_ ubrk_setUText(bi, ut, &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); + utext_close(ut); + return false; + } + size_t graphemes = 0; *bytes1 = 0; *bytes2 = 0; From ed4eee946d0084e2ca3db7e99c7f752ffa567d7e Mon Sep 17 00:00:00 2001 From: ChrisAbner Date: Wed, 4 Nov 2026 22:37:15 -0500 Subject: [PATCH 06/11] Test the size overflow checks of grapheme_str_pad() --- ext/intl/tests/grapheme_str_pad_overflow.phpt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 ext/intl/tests/grapheme_str_pad_overflow.phpt 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..6dd46c468fe7 --- /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 (Error $e) { + echo $e->getMessage(), PHP_EOL; +} +?> +--EXPECT-- +String size overflow +String size overflow From f16410830aa20cad02e64d750eea62a23374d658 Mon Sep 17 00:00:00 2001 From: ChrisAbner Date: Wed, 4 Nov 2026 22:37:54 -0500 Subject: [PATCH 07/11] Clear the intl error at the start of grapheme_str_pad() A successful call no longer leaves the error of a previous failed call, so intl_get_error_code() reflects the last call, like normalizer_normalize() and transliterator_transliterate(). --- ext/intl/grapheme/grapheme_string.cpp | 2 ++ ext/intl/tests/grapheme_str_pad_errors.phpt | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/ext/intl/grapheme/grapheme_string.cpp b/ext/intl/grapheme/grapheme_string.cpp index 502c3461e311..3babc45da76f 100644 --- a/ext/intl/grapheme/grapheme_string.cpp +++ b/ext/intl/grapheme/grapheme_string.cpp @@ -1271,6 +1271,8 @@ U_CFUNC PHP_FUNCTION(grapheme_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, diff --git a/ext/intl/tests/grapheme_str_pad_errors.phpt b/ext/intl/tests/grapheme_str_pad_errors.phpt index 4210eb203293..d8102dde3c55 100644 --- a/ext/intl/tests/grapheme_str_pad_errors.phpt +++ b/ext/intl/tests/grapheme_str_pad_errors.phpt @@ -25,6 +25,10 @@ var_dump(grapheme_str_pad("abc", 3, "-", 3)); var_dump(grapheme_str_pad("a\xffb", 5, ".")); var_dump(intl_get_error_message()); var_dump(grapheme_str_pad("ab\u{00FC}", 5, "\xff")); + +// 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-- grapheme_str_pad(): Argument #3 ($pad_string) must not be empty @@ -35,3 +39,5 @@ string(3) "abc" bool(false) string(62) "grapheme_str_pad(): Invalid UTF-8 string: U_INVALID_CHAR_FOUND" bool(false) +string(6) "abü.." +int(0) From 48b78964c1bb702e6736d14bb50a8f285c025e20 Mon Sep 17 00:00:00 2001 From: ChrisAbner Date: Wed, 4 Nov 2026 23:29:58 -0500 Subject: [PATCH 08/11] Count grapheme clusters through UTF-16 in grapheme_str_pad() Like grapheme_strlen(), convert the string with intl_convert_utf8_to_utf16(), which also rejects invalid UTF-8, and iterate the UTF-16 text, instead of validating with U8_NEXT() and iterating a UTF-8 UText. The byte length of a prefix of the padding string is taken from its UTF-16 offset. This is about a third faster on large non-ASCII strings and slightly faster on short ones. --- ext/intl/grapheme/grapheme_string.cpp | 49 +++++++++++++++------------ 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/ext/intl/grapheme/grapheme_string.cpp b/ext/intl/grapheme/grapheme_string.cpp index 3babc45da76f..0d6370033ee1 100644 --- a/ext/intl/grapheme/grapheme_string.cpp +++ b/ext/intl/grapheme/grapheme_string.cpp @@ -1152,6 +1152,17 @@ 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. */ @@ -1171,23 +1182,18 @@ static bool grapheme_str_pad_measure(const zend_string *str, size_t limit, size_ return false; } - /* Reject invalid UTF-8, like grapheme_strlen() does, instead of counting each invalid byte. */ - for (int32_t i = 0, len = (int32_t) ZSTR_LEN(str); i < len;) { - UChar32 c; - U8_NEXT(ZSTR_VAL(str), i, len, c); - if (c < 0) { - intl_error_set_code(nullptr, U_INVALID_CHAR_FOUND); - intl_error_set_custom_msg(nullptr, "Invalid UTF-8 string"); - return false; - } - } - + /* Like grapheme_strlen(), work on UTF-16. The conversion also rejects invalid UTF-8. */ UErrorCode ustatus = U_ZERO_ERROR; - UText *ut = utext_openUTF8(nullptr, ZSTR_VAL(str), ZSTR_LEN(str), &ustatus); + 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, "Error opening UTF-8 text"); + intl_error_set_custom_msg(nullptr, "Invalid UTF-8 string"); + if (ustr) { + efree(ustr); + } return false; } @@ -1196,38 +1202,39 @@ static bool grapheme_str_pad_measure(const zend_string *str, size_t limit, size_ if (U_FAILURE(ustatus)) { intl_error_set_code(nullptr, ustatus); intl_error_set_custom_msg(nullptr, "Error creating grapheme break iterator"); - utext_close(ut); + efree(ustr); return false; } - ubrk_setUText(bi, ut, &ustatus); + 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); - utext_close(ut); + efree(ustr); return false; } size_t graphemes = 0; - *bytes1 = 0; - *bytes2 = 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) { - *bytes1 = pos; + upos1 = pos; } if (graphemes == prefix2) { - *bytes2 = pos; + upos2 = pos; } } ubrk_close(bi); - utext_close(ut); + *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; } /* }}} */ From e197470b6a77274d4311cf2afe2ca798c50c7991 Mon Sep 17 00:00:00 2001 From: ChrisAbner Date: Thu, 5 Nov 2026 16:27:08 -0500 Subject: [PATCH 09/11] Test grapheme_str_pad() with fragments that merge at a join --- ext/intl/tests/grapheme_str_pad_merge.phpt | 32 ++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 ext/intl/tests/grapheme_str_pad_merge.phpt 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 From 371d81c41d563d9fd1dba0222fee9142d8cc7d2c Mon Sep 17 00:00:00 2001 From: ChrisAbner Date: Thu, 5 Nov 2026 16:46:27 -0500 Subject: [PATCH 10/11] Test that grapheme_str_pad() rejects invalid input even when no padding is needed --- ext/intl/tests/grapheme_str_pad_errors.phpt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ext/intl/tests/grapheme_str_pad_errors.phpt b/ext/intl/tests/grapheme_str_pad_errors.phpt index d8102dde3c55..8bda003ed37b 100644 --- a/ext/intl/tests/grapheme_str_pad_errors.phpt +++ b/ext/intl/tests/grapheme_str_pad_errors.phpt @@ -26,6 +26,9 @@ 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()); @@ -39,5 +42,6 @@ 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) From 99c217b19c61727bde60a5c5e8c975bae5195f0f Mon Sep 17 00:00:00 2001 From: ChrisAbner Date: Fri, 6 Nov 2026 09:28:21 -0500 Subject: [PATCH 11/11] Catch Throwable and print the exception class in grapheme_str_pad() tests --- ext/intl/tests/grapheme_str_pad_errors.phpt | 12 ++++++------ ext/intl/tests/grapheme_str_pad_overflow.phpt | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/ext/intl/tests/grapheme_str_pad_errors.phpt b/ext/intl/tests/grapheme_str_pad_errors.phpt index 8bda003ed37b..6e9d22e54281 100644 --- a/ext/intl/tests/grapheme_str_pad_errors.phpt +++ b/ext/intl/tests/grapheme_str_pad_errors.phpt @@ -6,14 +6,14 @@ intl getMessage(), PHP_EOL; +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; } try { grapheme_str_pad("abc", 5, "-", 3); -} catch (ValueError $e) { - echo $e->getMessage(), PHP_EOL; +} 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 @@ -34,8 +34,8 @@ var_dump(grapheme_str_pad("ab\u{00FC}", 5, ".")); var_dump(intl_get_error_code()); ?> --EXPECT-- -grapheme_str_pad(): Argument #3 ($pad_string) must not be empty -grapheme_str_pad(): Argument #4 ($pad_type) must be STR_PAD_LEFT, STR_PAD_RIGHT, or STR_PAD_BOTH +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" diff --git a/ext/intl/tests/grapheme_str_pad_overflow.phpt b/ext/intl/tests/grapheme_str_pad_overflow.phpt index 6dd46c468fe7..3337634e6163 100644 --- a/ext/intl/tests/grapheme_str_pad_overflow.phpt +++ b/ext/intl/tests/grapheme_str_pad_overflow.phpt @@ -9,17 +9,17 @@ intl // One side alone does not fit try { grapheme_str_pad("a", PHP_INT_MAX, "\u{1F600}"); -} catch (Error $e) { - echo $e->getMessage(), PHP_EOL; +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; } // Each side fits, both together do not try { grapheme_str_pad("a", PHP_INT_MAX, "\u{00E9}", STR_PAD_BOTH); -} catch (Error $e) { - echo $e->getMessage(), PHP_EOL; +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; } ?> --EXPECT-- -String size overflow -String size overflow +Error: String size overflow +Error: String size overflow