diff --git a/src/Symfony/Component/Translation/CHANGELOG.md b/src/Symfony/Component/Translation/CHANGELOG.md index 160b5e694fbcb..aefd72c8003bd 100644 --- a/src/Symfony/Component/Translation/CHANGELOG.md +++ b/src/Symfony/Component/Translation/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +6.1 +--- + + * Add new implementations of `TranslatableInterface` to format parameters + separately as recommended per the ICU for DateTime, money, and decimal values. + 5.4 --- diff --git a/src/Symfony/Component/Translation/Tests/Translatable/DateTimeTranslatableTest.php b/src/Symfony/Component/Translation/Tests/Translatable/DateTimeTranslatableTest.php new file mode 100644 index 0000000000000..bb8a6950ab7dd --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/Translatable/DateTimeTranslatableTest.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Tests\Translatable; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Translatable\DateTimeTranslatable; +use Symfony\Contracts\Translation\TranslatorInterface; + +class DateTimeTranslatableTest extends TestCase +{ + protected function setUp(): void + { + if (!\extension_loaded('intl')) { + $this->markTestSkipped('Extension intl is required.'); + } + } + + /** + * @dataProvider getValues() + */ + public function testFormat(string $expected, DateTimeTranslatable $parameter, string $locale) + { + $translator = $this->createMock(TranslatorInterface::class); + $this->assertSame($expected, $parameter->trans($translator, $locale)); + } + + public function getValues(): iterable + { + $dateTime = new \DateTime('2021-01-01 23:55:00', new \DateTimeZone('UTC')); + + $parameterDateTime = new DateTimeTranslatable($dateTime); + yield 'DateTime in French' => ['01/01/2021 23:55', $parameterDateTime, 'fr_FR']; + yield 'DateTime in GB English' => ['01/01/2021, 23:55', $parameterDateTime, 'en_GB']; + yield 'DateTime in US English' => ['1/1/21, 11:55 PM', $parameterDateTime, 'en_US']; + + $dateTimeParis = new \DateTime('2021-01-01 23:55:00', new \DateTimeZone('UTC')); + $dateTimeParis->setTimezone(new \DateTimeZone('Europe/Paris')); + + $parameterDateTimeParis = new DateTimeTranslatable($dateTimeParis); + yield 'DateTime in Paris in French' => ['02/01/2021 00:55', $parameterDateTimeParis, 'fr_FR']; + yield 'DateTime in Paris in GB English' => ['02/01/2021, 00:55', $parameterDateTimeParis, 'en_GB']; + yield 'DateTime in Paris in US English' => ['1/2/21, 12:55 AM', $parameterDateTimeParis, 'en_US']; + + $parameterDateParis = DateTimeTranslatable::date($dateTimeParis); + yield 'Date in Paris in French' => ['02/01/2021', $parameterDateParis, 'fr_FR']; + yield 'Date in Paris in GB English' => ['02/01/2021', $parameterDateParis, 'en_GB']; + yield 'Date in Paris in US English' => ['1/2/21', $parameterDateParis, 'en_US']; + + $parameterTimeParis = DateTimeTranslatable::time($dateTimeParis); + yield 'Time in Paris in French' => ['00:55', $parameterTimeParis, 'fr_FR']; + yield 'Time in Paris in GB English' => ['00:55', $parameterTimeParis, 'en_GB']; + yield 'Time in Paris in US English' => ['12:55 AM', $parameterTimeParis, 'en_US']; + } +} diff --git a/src/Symfony/Component/Translation/Tests/Translatable/DecimalTranslatableTest.php b/src/Symfony/Component/Translation/Tests/Translatable/DecimalTranslatableTest.php new file mode 100644 index 0000000000000..3b356e1996d3c --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/Translatable/DecimalTranslatableTest.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Tests\Translatable; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Translatable\DecimalTranslatable; +use Symfony\Contracts\Translation\TranslatorInterface; + +class DecimalTranslatableTest extends TestCase +{ + protected function setUp(): void + { + if (!\extension_loaded('intl')) { + $this->markTestSkipped('Extension intl is required.'); + } + } + + /** + * @dataProvider getValues() + */ + public function testFormat(string $expected, DecimalTranslatable $parameter, string $locale) + { + $translator = $this->createMock(TranslatorInterface::class); + // Non-breakable spaces are added differently depending the PHP version + $cleaned = str_replace(["\u{202f}", "\u{a0}"], ['', ''], $parameter->trans($translator, $locale)); + $this->assertSame($expected, $cleaned); + } + + public function getValues(): iterable + { + $parameter = new DecimalTranslatable(1000); + + yield 'French' => ['1000', $parameter, 'fr_FR']; + yield 'US English' => ['1,000', $parameter, 'en_US']; + + $parameter = new DecimalTranslatable(1000.01); + + yield 'Float in French' => ['1000,01', $parameter, 'fr_FR']; + yield 'Float in US English' => ['1,000.01', $parameter, 'en_US']; + + $parameter = new DecimalTranslatable(1, \NumberFormatter::PERCENT); + + yield 'Styled in French' => ['100%', $parameter, 'fr_FR']; + yield 'Styled in US English' => ['100%', $parameter, 'en_US']; + } +} diff --git a/src/Symfony/Component/Translation/Tests/Translatable/MoneyTranslatableTest.php b/src/Symfony/Component/Translation/Tests/Translatable/MoneyTranslatableTest.php new file mode 100644 index 0000000000000..056795290f17a --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/Translatable/MoneyTranslatableTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Tests\Translatable; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Translatable\MoneyTranslatable; +use Symfony\Contracts\Translation\TranslatorInterface; + +class MoneyTranslatableTest extends TestCase +{ + protected function setUp(): void + { + if (!\extension_loaded('intl')) { + $this->markTestSkipped('Extension intl is required.'); + } + } + + /** + * @dataProvider getValues() + */ + public function testTrans(string $expected, MoneyTranslatable $parameter, string $locale) + { + $translator = $this->createMock(TranslatorInterface::class); + // DecimalMoneyFormatter output may contain non-breakable spaces: + // - this is done for good reasons + // - output "style" changes depending on the PHP version + // This normalization in only done here in the test so a new PHP version won't break the test + $normalized = str_replace(["\u{202f}", "\u{a0}"], ['', ''], $parameter->trans($translator, $locale)); + $this->assertSame($expected, $normalized); + } + + public function getValues(): iterable + { + $parameterEuros = new MoneyTranslatable(1000, 'EUR'); + $parameterDollars = new MoneyTranslatable(1000, 'USD'); + + yield 'Euros in French' => ['1000,00€', $parameterEuros, 'fr_FR']; + yield 'Euros in US English' => ['€1,000.00', $parameterEuros, 'en_US']; + yield 'US Dollars in French' => ['1000,00$US', $parameterDollars, 'fr_FR']; + yield 'US Dollars in US English' => ['$1,000.00', $parameterDollars, 'en_US']; + + if (\defined('\NumberFormatter::CURRENCY_ACCOUNTING')) { + $parameterEuros = new MoneyTranslatable(-1000, 'EUR', \NumberFormatter::CURRENCY_ACCOUNTING); + yield 'Accounting style in French' => ['(1000,00€)', $parameterEuros, 'fr_FR']; + yield 'Accounting style in US English' => ['(€1,000.00)', $parameterEuros, 'en_US']; + } + } +} diff --git a/src/Symfony/Component/Translation/Translatable/DateTimeTranslatable.php b/src/Symfony/Component/Translation/Translatable/DateTimeTranslatable.php new file mode 100644 index 0000000000000..c4f2cdf7aa5c9 --- /dev/null +++ b/src/Symfony/Component/Translation/Translatable/DateTimeTranslatable.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Translatable; + +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * Wrapper around PHP IntlDateFormatter for date and time + * The timezone from the DateTime instance is used instead of the server's timezone. + * + * Implementation of the ICU recommendation to first format advanced parameters before translation. + * + * @see https://unicode-org.github.io/icu/userguide/format_parse/messages/#format-the-parameters-separately-recommended + * + * @author Sylvain Fabre + */ +class DateTimeTranslatable implements TranslatableInterface +{ + private \DateTimeInterface $dateTime; + private int $dateType; + private int $timeType; + + private static array $formatters = []; + + public function __construct( + \DateTimeInterface $dateTime, + int $dateType = \IntlDateFormatter::SHORT, + int $timeType = \IntlDateFormatter::SHORT + ) { + $this->dateTime = $dateTime; + $this->dateType = $dateType; + $this->timeType = $timeType; + } + + public function trans(TranslatorInterface $translator, string $locale = null): string + { + if (!$locale) { + $locale = $translator->getLocale(); + } + + $timezone = $this->dateTime->getTimezone(); + $key = implode('.', [$locale, $this->dateType, $this->timeType, $timezone->getName()]); + if (!isset(self::$formatters[$key])) { + self::$formatters[$key] = new \IntlDateFormatter( + $locale ?? $translator->getLocale(), + $this->dateType, + $this->timeType, + $timezone + ); + } + + return self::$formatters[$key]->format($this->dateTime); + } + + /** + * Short-hand to only format a date. + */ + public static function date(\DateTimeInterface $dateTime, int $type = \IntlDateFormatter::SHORT): self + { + return new self($dateTime, $type, \IntlDateFormatter::NONE); + } + + /** + * Short-hand to only format a time. + */ + public static function time(\DateTimeInterface $dateTime, int $type = \IntlDateFormatter::SHORT): self + { + return new self($dateTime, \IntlDateFormatter::NONE, $type); + } +} diff --git a/src/Symfony/Component/Translation/Translatable/DecimalTranslatable.php b/src/Symfony/Component/Translation/Translatable/DecimalTranslatable.php new file mode 100644 index 0000000000000..e0b17b710a356 --- /dev/null +++ b/src/Symfony/Component/Translation/Translatable/DecimalTranslatable.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Translatable; + +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * Wrapper around PHP NumberFormatter for decimal values. + * + * Implementation of the ICU recommendation to first format advanced parameters before translation. + * + * @see https://unicode-org.github.io/icu/userguide/format_parse/messages/#format-the-parameters-separately-recommended + * + * @author Sylvain Fabre + */ +class DecimalTranslatable implements TranslatableInterface +{ + private float|int $value; + private int $style; + + private static array $formatters = []; + + public function __construct(float|int $value, int $style = \NumberFormatter::DECIMAL) + { + $this->value = $value; + $this->style = $style; + } + + public function trans(TranslatorInterface $translator, string $locale = null): string + { + if (!$locale) { + $locale = $translator->getLocale(); + } + + $key = implode('.', [$locale, $this->style]); + if (!isset(self::$formatters[$key])) { + self::$formatters[$key] = new \NumberFormatter($locale, $this->style); + } + + return self::$formatters[$key]->format($this->value); + } +} diff --git a/src/Symfony/Component/Translation/Translatable/MoneyTranslatable.php b/src/Symfony/Component/Translation/Translatable/MoneyTranslatable.php new file mode 100644 index 0000000000000..7bae1629270d7 --- /dev/null +++ b/src/Symfony/Component/Translation/Translatable/MoneyTranslatable.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Translatable; + +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * Wrapper around PHP NumberFormatter for money + * The provided currency is used instead of the locale's currency. + * + * Implementation of the ICU recommendation to first format advanced parameters before translation. + * + * @see https://unicode-org.github.io/icu/userguide/format_parse/messages/#format-the-parameters-separately-recommended + * + * @author Sylvain Fabre + */ +class MoneyTranslatable implements TranslatableInterface +{ + private float|int $value; + private string $currency; + private int $style; + + private static array $formatters = []; + + public function __construct(float|int $value, string $currency, int $style = \NumberFormatter::CURRENCY) + { + $this->value = $value; + $this->currency = $currency; + $this->style = $style; + } + + public function trans(TranslatorInterface $translator, string $locale = null): string + { + if (!$locale) { + $locale = $translator->getLocale(); + } + + $key = implode('.', [$locale, $this->style]); + if (!isset(self::$formatters[$key])) { + self::$formatters[$key] = new \NumberFormatter($locale, $this->style); + } + + return self::$formatters[$key]->formatCurrency($this->value, $this->currency); + } +} diff --git a/src/Symfony/Component/Translation/composer.json b/src/Symfony/Component/Translation/composer.json index abe8b972c13d4..4bbbab4a97954 100644 --- a/src/Symfony/Component/Translation/composer.json +++ b/src/Symfony/Component/Translation/composer.json @@ -47,7 +47,8 @@ "suggest": { "symfony/config": "", "symfony/yaml": "", - "psr/log-implementation": "To use logging capability in translator" + "psr/log-implementation": "To use logging capability in translator", + "moneyphp/money": "To use money capability in translator" }, "autoload": { "files": [ "Resources/functions.php" ],