diff --git a/UPGRADING b/UPGRADING index 46cf4cafab12..34f90e521bb2 100644 --- a/UPGRADING +++ b/UPGRADING @@ -68,6 +68,14 @@ PHP 8.7 UPGRADE NOTES 7. New Classes and Interfaces ======================================== +- Intl: + . Added IntlNumberFormatter class to format a number with a given locale + and ICU number skeleton, which also covers measurement units, e.g. + "measure-unit/speed-kilometer-per-hour unit-width-full-name". + IntlNumberFormatter::format() accepts an int, a float or a numeric + string, the latter being formatted as a decimal of arbitrary precision. + It is supported from icu 63. + ======================================== 8. Removed Extensions and SAPIs ======================================== diff --git a/ext/intl/CREDITS b/ext/intl/CREDITS index 0e7fd55dc12e..02a0f12a139d 100644 --- a/ext/intl/CREDITS +++ b/ext/intl/CREDITS @@ -1,2 +1,2 @@ Internationalization -Ed Batutis, Vladimir Iordanov, Dmitry Lakhtyuk, Stanislav Malyshev, Vadim Savchuk, Kirti Velankar +Ed Batutis, David Carlier, Vladimir Iordanov, Dmitry Lakhtyuk, Stanislav Malyshev, Vadim Savchuk, Kirti Velankar diff --git a/ext/intl/config.m4 b/ext/intl/config.m4 index f6a01053df20..0f426e45af73 100644 --- a/ext/intl/config.m4 +++ b/ext/intl/config.m4 @@ -48,6 +48,7 @@ if test "$PHP_INTL" != "no"; then grapheme/grapheme_string.cpp \ grapheme/grapheme_util.cpp \ msgformat/msgformat_helpers.cpp \ + numberformatter/numberformatter_class.cpp \ rangeformatter/rangeformatter_class.cpp \ timezone/timezone_class.cpp \ timezone/timezone_methods.cpp \ @@ -126,6 +127,7 @@ if test "$PHP_INTL" != "no"; then $ext_builddir/listformatter $ext_builddir/msgformat $ext_builddir/normalizer + $ext_builddir/numberformatter $ext_builddir/rangeformatter $ext_builddir/resourcebundle $ext_builddir/spoofchecker diff --git a/ext/intl/config.w32 b/ext/intl/config.w32 index 3cde431ce66e..8ca7096152b3 100644 --- a/ext/intl/config.w32 +++ b/ext/intl/config.w32 @@ -63,6 +63,9 @@ if (PHP_INTL != "no") { normalizer_class.cpp \ normalizer_normalize.cpp \ ", "intl"); + ADD_SOURCES(configure_module_dirname + "/numberformatter", "\ + numberformatter_class.cpp \ + ", "intl"); ADD_SOURCES(configure_module_dirname + "/rangeformatter", "\ rangeformatter_class.cpp \ ", "intl"); diff --git a/ext/intl/numberformatter/numberformatter.stub.php b/ext/intl/numberformatter/numberformatter.stub.php new file mode 100644 index 000000000000..c98bb3cee389 --- /dev/null +++ b/ext/intl/numberformatter/numberformatter.stub.php @@ -0,0 +1,19 @@ +. | + | | + | SPDX-License-Identifier: BSD-3-Clause | + +----------------------------------------------------------------------+ + | Authors: David Carlier | + +----------------------------------------------------------------------+ +*/ + +extern "C" { +#include "php.h" +#include "zend_API.h" +#include "../intl_common.h" +} + +#if U_ICU_VERSION_MAJOR_NUM >= 63 +#include +#include +#include +#include "../intl_convertcpp.h" + +extern "C" { +#include "../php_intl.h" +#include "numberformatter_arginfo.h" +} +#include "numberformatter_class.h" + +using icu::number::NumberFormatter; +using icu::number::UnlocalizedNumberFormatter; +using icu::UnicodeString; + +static zend_object_handlers numberformatter_handlers; +zend_class_entry *IntlNumberFormatter_ce_ptr; + +static void numberformatter_free_obj(zend_object *object) +{ + IntlNumberFormatter_object *nfo = php_intl_numberformatter_fetch_object(object); + + delete NUMBERFORMATTER_OBJECT(nfo); + NUMBERFORMATTER_OBJECT(nfo) = nullptr; + intl_error_reset(INTL_DATA_ERROR_P(nfo)); + + zend_object_std_dtor(&nfo->zo); +} + +static zend_object *numberformatter_create_object(zend_class_entry *ce) +{ + IntlNumberFormatter_object *nfo = reinterpret_cast(zend_object_alloc(sizeof(IntlNumberFormatter_object), ce)); + + intl_error_init(INTL_DATA_ERROR_P(nfo)); + NUMBERFORMATTER_OBJECT(nfo) = nullptr; + + zend_object_std_init(&nfo->zo, ce); + object_properties_init(&nfo->zo, ce); + nfo->zo.handlers = &numberformatter_handlers; + + return &nfo->zo; +} + +U_CFUNC PHP_METHOD(IntlNumberFormatter, __construct) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + zend_throw_error(nullptr, "Cannot directly construct %s, use createFromSkeleton method instead", ZSTR_VAL(Z_OBJCE_P(ZEND_THIS)->name)); +} + +U_CFUNC PHP_METHOD(IntlNumberFormatter, createFromSkeleton) +{ + char *skeleton, *locale; + size_t skeleton_len, locale_len; + + ZEND_PARSE_PARAMETERS_START(2, 2) + Z_PARAM_STRING(skeleton, skeleton_len) + Z_PARAM_STRING(locale, locale_len) + ZEND_PARSE_PARAMETERS_END(); + + intl_error_reset(nullptr); + + INTL_CHECK_LOCALE_LEN(locale_len); + + if (locale_len == 0) { + locale = (char *)intl_locale_get_default(); + } + + if (icu::Locale(locale).getISO3Language()[0] == '\0') { + zend_argument_value_error(2, "\"%s\" is invalid", locale); + RETURN_THROWS(); + } + + UErrorCode status = U_ZERO_ERROR; + UnicodeString uskeleton; + + intl_stringFromChar(uskeleton, skeleton, skeleton_len, &status); + INTL_CHECK_STATUS_OR_NULL(status, "Error converting skeleton to UTF-16"); + + UnlocalizedNumberFormatter nf = NumberFormatter::forSkeleton(uskeleton, status); + INTL_CHECK_STATUS_OR_NULL(status, "Number formatter creation failed"); + + object_init_ex(return_value, IntlNumberFormatter_ce_ptr); + NUMBERFORMATTER_OBJECT(Z_INTL_INTLNUMBERFORMATTER_P(return_value)) = new LocalizedNumberFormatter(nf.locale(icu::Locale::createCanonical(locale))); +} + +U_CFUNC PHP_METHOD(IntlNumberFormatter, format) +{ + zval *value; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_NUMBER_OR_STR(value) + ZEND_PARSE_PARAMETERS_END(); + + IntlNumberFormatter_object *nfo = Z_INTL_INTLNUMBERFORMATTER_P(ZEND_THIS); + intl_errors_reset(INTL_DATA_ERROR_P(nfo)); + + UErrorCode status = U_ZERO_ERROR; + UnicodeString result; + + if (Z_TYPE_P(value) == IS_STRING) { + if (UNEXPECTED(zend_str_has_nul_byte(Z_STR_P(value)))) { + zend_argument_value_error(1, "must not contain any null bytes"); + RETURN_THROWS(); + } + if (UNEXPECTED(Z_STRLEN_P(value) > INT32_MAX)) { + zend_argument_value_error(1, "is too long"); + RETURN_THROWS(); + } + result = NUMBERFORMATTER_OBJECT(nfo)->formatDecimal(icu::StringPiece(Z_STRVAL_P(value), static_cast(Z_STRLEN_P(value))), status).toString(status); + } else if (Z_TYPE_P(value) == IS_DOUBLE) { + result = NUMBERFORMATTER_OBJECT(nfo)->formatDouble(Z_DVAL_P(value), status).toString(status); + } else { + result = NUMBERFORMATTER_OBJECT(nfo)->formatInt(static_cast(Z_LVAL_P(value)), status).toString(status); + } + + if (U_FAILURE(status)) { + intl_errors_set(INTL_DATA_ERROR_P(nfo), status, "Number formatting failed"); + RETURN_FALSE; + } + + zend_string *ret = intl_charFromString(result, &status); + + if (!ret) { + intl_errors_set(INTL_DATA_ERROR_P(nfo), status, "Error converting value to UTF-8"); + RETURN_FALSE; + } + + RETVAL_STR(ret); +} + +U_CFUNC PHP_METHOD(IntlNumberFormatter, getErrorCode) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + RETURN_LONG(INTL_DATA_ERROR_CODE(Z_INTL_INTLNUMBERFORMATTER_P(ZEND_THIS))); +} + +U_CFUNC PHP_METHOD(IntlNumberFormatter, getErrorMessage) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + RETURN_STR(intl_error_get_message(INTL_DATA_ERROR_P(Z_INTL_INTLNUMBERFORMATTER_P(ZEND_THIS)))); +} + +void numberformatter_register_class(void) +{ + IntlNumberFormatter_ce_ptr = register_class_IntlNumberFormatter(); + IntlNumberFormatter_ce_ptr->create_object = numberformatter_create_object; + + memcpy(&numberformatter_handlers, zend_get_std_object_handlers(), sizeof(zend_object_handlers)); + numberformatter_handlers.offset = offsetof(IntlNumberFormatter_object, zo); + numberformatter_handlers.free_obj = numberformatter_free_obj; + numberformatter_handlers.clone_obj = nullptr; +} +#endif diff --git a/ext/intl/numberformatter/numberformatter_class.h b/ext/intl/numberformatter/numberformatter_class.h new file mode 100644 index 000000000000..79ec5895ac57 --- /dev/null +++ b/ext/intl/numberformatter/numberformatter_class.h @@ -0,0 +1,60 @@ +/* + +----------------------------------------------------------------------+ + | Copyright © The PHP Group and Contributors. | + +----------------------------------------------------------------------+ + | This source file is subject to the Modified BSD License that is | + | bundled with this package in the file LICENSE, and is available | + | through the World Wide Web at . | + | | + | SPDX-License-Identifier: BSD-3-Clause | + +----------------------------------------------------------------------+ + | Authors: David Carlier | + +----------------------------------------------------------------------+ +*/ + +#ifndef NUMBERFORMATTER_CLASS_H +#define NUMBERFORMATTER_CLASS_H + +#ifdef __cplusplus +extern "C" { +#endif +#include +#include "intl_common.h" +#include "intl_error.h" +#include "intl_data.h" +#ifdef __cplusplus +} +#endif + +#ifdef __cplusplus +#include +using icu::number::LocalizedNumberFormatter; +#else +typedef void LocalizedNumberFormatter; +#endif + +typedef struct { + intl_error error; + LocalizedNumberFormatter *unumf; +} numberformatter_data; + +typedef struct { + numberformatter_data nf_data; + zend_object zo; +} IntlNumberFormatter_object; + +#define php_intl_numberformatter_fetch_object(obj) ZEND_CONTAINER_OF(obj, IntlNumberFormatter_object, zo) +#define Z_INTL_INTLNUMBERFORMATTER_P(zv) php_intl_numberformatter_fetch_object(Z_OBJ_P(zv)) + +#define NUMBERFORMATTER_OBJECT(nfo) (nfo)->nf_data.unumf + +#ifdef __cplusplus +extern "C" { +#endif +void numberformatter_register_class(void); +extern zend_class_entry *IntlNumberFormatter_ce_ptr; +#ifdef __cplusplus +} +#endif + +#endif diff --git a/ext/intl/php_intl.c b/ext/intl/php_intl.c index ce938b915595..5b51d98dba04 100644 --- a/ext/intl/php_intl.c +++ b/ext/intl/php_intl.c @@ -42,6 +42,7 @@ #include "locale/locale_class.h" #include "listformatter/listformatter_class.h" +#include "numberformatter/numberformatter_class.h" #include "rangeformatter/rangeformatter_class.h" #include "dateformat/dateformat.h" @@ -190,6 +191,9 @@ PHP_MINIT_FUNCTION( intl ) listformatter_register_class( ); #if U_ICU_VERSION_MAJOR_NUM >= 63 + /* Register 'IntlNumberFormatter' PHP class */ + numberformatter_register_class( ); + /* Register 'NumberRangeFormatter' PHP class */ rangeformatter_register_class( ); #endif diff --git a/ext/intl/tests/numberformatter/basic.phpt b/ext/intl/tests/numberformatter/basic.phpt new file mode 100644 index 000000000000..e499571962fa --- /dev/null +++ b/ext/intl/tests/numberformatter/basic.phpt @@ -0,0 +1,58 @@ +--TEST-- +Basic test for IntlNumberFormatter +--EXTENSIONS-- +intl +--SKIPIF-- + +--FILE-- +format($value)); +} + +var_dump($formatter->getErrorCode()); +var_dump($formatter->getErrorMessage()); + +?> +--EXPECT-- +string(6) "5 km/h" +string(21) "5 kilometers per hour" +string(20) "1 kilometer per hour" +string(23) "1.5 kilometers per hour" +string(24) "1,5 Kilometer pro Stunde" +string(5) "5 m/s" +string(5) "1.5GB" +string(14) "1,234.50 euros" +string(3) "25%" +string(4) "1.2M" +string(8) "1,234.50" +string(7) "1234567" +string(2) "+5" +string(10) "1,234.5678" +string(3) "NaN" +int(0) +string(12) "U_ZERO_ERROR" diff --git a/ext/intl/tests/numberformatter/default_locale.phpt b/ext/intl/tests/numberformatter/default_locale.phpt new file mode 100644 index 000000000000..2e6671c70876 --- /dev/null +++ b/ext/intl/tests/numberformatter/default_locale.phpt @@ -0,0 +1,29 @@ +--TEST-- +IntlNumberFormatter::createFromSkeleton() with an empty locale uses the default locale +--EXTENSIONS-- +intl +--SKIPIF-- + +--INI-- +intl.default_locale=en_US +--FILE-- +format(1234.5)); + +Locale::setDefault('de'); + +var_dump(IntlNumberFormatter::createFromSkeleton($skeleton, '')->format(1234.5)); +var_dump(IntlNumberFormatter::createFromSkeleton($skeleton, 'en_US')->format(1234.5)); + +?> +--EXPECT-- +string(27) "1,234.5 kilometers per hour" +string(28) "1.234,5 Kilometer pro Stunde" +string(27) "1,234.5 kilometers per hour" diff --git a/ext/intl/tests/numberformatter/errors.phpt b/ext/intl/tests/numberformatter/errors.phpt new file mode 100644 index 000000000000..308f29faf5b8 --- /dev/null +++ b/ext/intl/tests/numberformatter/errors.phpt @@ -0,0 +1,86 @@ +--TEST-- +Errors for IntlNumberFormatter +--EXTENSIONS-- +intl +--SKIPIF-- + +--FILE-- +getMessage(), PHP_EOL; +} + +ini_set('intl.use_exceptions', '0'); + +try { + new IntlNumberFormatter(); +} catch (Error $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} + +try { + IntlNumberFormatter::createFromSkeleton('', 'invalid-language'); +} catch (ValueError $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} + +IntlNumberFormatter::createFromSkeleton('invalid skeleton here', 'ro'); + +try { + $formatter->format([]); +} catch (TypeError $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} + +var_dump(intl_get_error_code()); + +try { + clone $formatter; +} catch (Error $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} + +?> +--EXPECT-- +NULL +int(65811) +string(107) "IntlNumberFormatter::createFromSkeleton(): Number formatter creation failed: U_NUMBER_SKELETON_SYNTAX_ERROR" +NULL +string(107) "IntlNumberFormatter::createFromSkeleton(): Number formatter creation failed: U_NUMBER_SKELETON_SYNTAX_ERROR" +NULL +string(132) "IntlNumberFormatter::createFromSkeleton(): Locale string too long, should be no longer than 156 characters: U_ILLEGAL_ARGUMENT_ERROR" +NULL +string(100) "IntlNumberFormatter::createFromSkeleton(): Error converting skeleton to UTF-16: U_INVALID_CHAR_FOUND" +int(0) +IntlException: IntlNumberFormatter::createFromSkeleton(): Number formatter creation failed +Error: Call to private IntlNumberFormatter::__construct() from global scope +ValueError: IntlNumberFormatter::createFromSkeleton(): Argument #2 ($locale) "invalid-language" is invalid +TypeError: IntlNumberFormatter::format(): Argument #1 ($value) must be of type string|int|float, array given +int(65811) +Error: Trying to clone an uncloneable object of class IntlNumberFormatter diff --git a/ext/intl/tests/numberformatter/format_failure.phpt b/ext/intl/tests/numberformatter/format_failure.phpt new file mode 100644 index 000000000000..ff91a9ca1bbf --- /dev/null +++ b/ext/intl/tests/numberformatter/format_failure.phpt @@ -0,0 +1,48 @@ +--TEST-- +IntlNumberFormatter::format() with a failing formatter +--EXTENSIONS-- +intl +--SKIPIF-- + +--FILE-- +format(1)); +var_dump($formatter->getErrorCode() === U_UNSUPPORTED_ERROR); +var_dump($formatter->getErrorMessage()); +var_dump(intl_get_error_code() === U_UNSUPPORTED_ERROR); + +ini_set('intl.use_exceptions', '1'); + +try { + $formatter->format(1); +} catch (IntlException $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} + +ini_set('intl.use_exceptions', '0'); + +$formatter = IntlNumberFormatter::createFromSkeleton('', 'en_US'); + +var_dump($formatter->format(1)); +var_dump(intl_get_error_code()); +var_dump($formatter->getErrorCode()); +var_dump($formatter->getErrorMessage()); + +?> +--EXPECT-- +bool(false) +bool(true) +string(76) "IntlNumberFormatter::format(): Number formatting failed: U_UNSUPPORTED_ERROR" +bool(true) +IntlException: IntlNumberFormatter::format(): Number formatting failed +string(1) "1" +int(0) +int(0) +string(12) "U_ZERO_ERROR" diff --git a/ext/intl/tests/numberformatter/format_int64.phpt b/ext/intl/tests/numberformatter/format_int64.phpt new file mode 100644 index 000000000000..28e706232436 --- /dev/null +++ b/ext/intl/tests/numberformatter/format_int64.phpt @@ -0,0 +1,25 @@ +--TEST-- +IntlNumberFormatter::format() preserves int64 precision +--EXTENSIONS-- +intl +--SKIPIF-- + +--FILE-- +format(9007199254740993)); +var_dump($formatter->format(PHP_INT_MIN)); + +?> +--EXPECT-- +string(21) "9,007,199,254,740,993" +string(26) "-9,223,372,036,854,775,808" diff --git a/ext/intl/tests/numberformatter/format_string.phpt b/ext/intl/tests/numberformatter/format_string.phpt new file mode 100644 index 000000000000..51ac283c0962 --- /dev/null +++ b/ext/intl/tests/numberformatter/format_string.phpt @@ -0,0 +1,56 @@ +--TEST-- +IntlNumberFormatter::format() with a numeric string +--EXTENSIONS-- +intl +--SKIPIF-- + +--FILE-- +format('12345678901234567890.123456789')); +var_dump($formatter->format('9223372036854775808')); +var_dump($formatter->format('-0.1')); +var_dump($formatter->format('1e3')); + +var_dump(IntlNumberFormatter::createFromSkeleton('', 'en_US')->format('12345678901234567890.123456789')); +var_dump(IntlNumberFormatter::createFromSkeleton('measure-unit/length-meter .00', 'de')->format('1234.5')); + +foreach (['abc', '', ' 12', '1,5'] as $value) { + var_dump($formatter->format($value)); + var_dump($formatter->getErrorMessage()); +} + +var_dump($formatter->format('12')); +var_dump($formatter->getErrorCode()); + +try { + $formatter->format("1\0002"); +} catch (ValueError $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} + +?> +--EXPECT-- +string(36) "12,345,678,901,234,567,890.123456789" +string(25) "9,223,372,036,854,775,808" +string(4) "-0.1" +string(5) "1,000" +string(33) "12,345,678,901,234,567,890.123457" +string(10) "1.234,50 m" +bool(false) +string(86) "IntlNumberFormatter::format(): Number formatting failed: U_DECIMAL_NUMBER_SYNTAX_ERROR" +bool(false) +string(86) "IntlNumberFormatter::format(): Number formatting failed: U_DECIMAL_NUMBER_SYNTAX_ERROR" +bool(false) +string(86) "IntlNumberFormatter::format(): Number formatting failed: U_DECIMAL_NUMBER_SYNTAX_ERROR" +bool(false) +string(86) "IntlNumberFormatter::format(): Number formatting failed: U_DECIMAL_NUMBER_SYNTAX_ERROR" +string(2) "12" +int(0) +ValueError: IntlNumberFormatter::format(): Argument #1 ($value) must not contain any null bytes diff --git a/ext/intl/tests/numberformatter/locale_posix_modifier.phpt b/ext/intl/tests/numberformatter/locale_posix_modifier.phpt new file mode 100644 index 000000000000..89db9664811f --- /dev/null +++ b/ext/intl/tests/numberformatter/locale_posix_modifier.phpt @@ -0,0 +1,21 @@ +--TEST-- +IntlNumberFormatter preserves the region of a locale with a POSIX modifier +--EXTENSIONS-- +intl +--SKIPIF-- + +--FILE-- +format(12345.5) === $modified->format(12345.5)); +?> +--EXPECT-- +bool(true) diff --git a/ext/standard/credits_ext.h b/ext/standard/credits_ext.h index 3e1ba70a71e5..45f8ce89c8d9 100644 --- a/ext/standard/credits_ext.h +++ b/ext/standard/credits_ext.h @@ -31,7 +31,7 @@ CREDIT_LINE("GetText", "Alex Plotnick"); CREDIT_LINE("GNU GMP support", "Stanislav Malyshev"); CREDIT_LINE("Iconv", "Rui Hirokawa, Stig Bakken, Moriyoshi Koizumi"); CREDIT_LINE("Input Filter", "Rasmus Lerdorf, Derick Rethans, Pierre-Alain Joye, Ilia Alshanetsky"); -CREDIT_LINE("Internationalization", "Ed Batutis, Vladimir Iordanov, Dmitry Lakhtyuk, Stanislav Malyshev, Vadim Savchuk, Kirti Velankar"); +CREDIT_LINE("Internationalization", "Ed Batutis, David Carlier, Vladimir Iordanov, Dmitry Lakhtyuk, Stanislav Malyshev, Vadim Savchuk, Kirti Velankar"); CREDIT_LINE("JSON", "Jakub Zelenka, Omar Kilani, Scott MacVicar"); CREDIT_LINE("LDAP", "Amitay Isaacs, Eric Warnke, Rasmus Lerdorf, Gerrit Thomson, Stig Venaas"); CREDIT_LINE("LIBXML", "Christian Stocker, Rob Richards, Marcus Boerger, Wez Furlong, Shane Caraveo");