From 90fda2577c20b2eb81c2dfc63a932d21138360e1 Mon Sep 17 00:00:00 2001 From: Ben Ramsey Date: Fri, 4 Feb 2022 14:15:34 -0600 Subject: [PATCH 1/2] fix: multiply percentages by 100 internally to match FormatJS --- src/Intl/MessageFormat.php | 96 +++++++++++++++++++++++++++----- tests/Intl/MessageFormatTest.php | 52 +++++++++++++++++ 2 files changed, 133 insertions(+), 15 deletions(-) diff --git a/src/Intl/MessageFormat.php b/src/Intl/MessageFormat.php index 21a839b..84957dd 100644 --- a/src/Intl/MessageFormat.php +++ b/src/Intl/MessageFormat.php @@ -40,6 +40,7 @@ use function assert; use function is_callable; use function is_int; +use function is_numeric; use function preg_match; use function sprintf; @@ -68,7 +69,7 @@ public function __construct(?LocaleInterface $locale = null) public function format(string $pattern, array $values = []): string { try { - $pattern = $this->applyCallbacks($pattern, $values); + $pattern = $this->applyPreprocessing($pattern, $values); $formatter = new PhpMessageFormatter((string) $this->locale->baseName(), $pattern); $formattedMessage = $formatter->format($values); @@ -107,21 +108,19 @@ public function format(string $pattern, array $values = []): string * @throws UnableToFormatMessageException * @throws CollectionMismatchException */ - private function applyCallbacks(string $pattern, array &$values = []): string + private function applyPreprocessing(string $pattern, array &$values = []): string { $callbacks = array_filter($values, fn ($value): bool => is_callable($value)); - // If $values doesn't contain any callables, go ahead and return. - if (!$callbacks) { - return $pattern; - } - // Remove the callbacks from the values, since we will use them below. foreach (array_keys($callbacks) as $key) { unset($values[$key]); } - $parser = new Parser($pattern); + $parserOptions = new Parser\Options(); + $parserOptions->shouldParseSkeletons = true; + + $parser = new Parser($pattern, $parserOptions); $parsed = $parser->parse(); if ($parsed->err !== null) { @@ -130,18 +129,20 @@ private function applyCallbacks(string $pattern, array &$values = []): string assert($parsed->val instanceof Parser\Type\ElementCollection); - return (new Printer())->printAst($this->processAstWithCallbacks($parsed->val, $callbacks)); + return (new Printer())->printAst($this->processAst($parsed->val, $callbacks, $values)); } /** * @param array $callbacks + * @param array $values * * @throws CollectionMismatchException * @throws UnableToFormatMessageException */ - private function processAstWithCallbacks( + private function processAst( Parser\Type\ElementCollection $ast, - array $callbacks + array $callbacks, + array &$values ): Parser\Type\ElementCollection { $processedAst = new Parser\Type\ElementCollection(); @@ -152,16 +153,20 @@ private function processAstWithCallbacks( if ($clone instanceof PluralElement || $clone instanceof SelectElement) { foreach ($clone->options as $option) { - $option->value = $this->processAstWithCallbacks($option->value, $callbacks); + $option->value = $this->processAst($option->value, $callbacks, $values); } } if ($clone instanceof Parser\Type\TagElement) { - $processedAst = $processedAst->merge($this->processTagElement($clone, $callbacks)); + $processedAst = $processedAst->merge($this->processTagElement($clone, $callbacks, $values)); continue; } + if ($clone instanceof Parser\Type\NumberElement) { + $clone = $this->processNumberElement($clone, $values); + } + if ($clone instanceof Parser\Type\LiteralElement) { $clone = $this->processLiteralElement($clone, $callbacks); } @@ -174,13 +179,15 @@ private function processAstWithCallbacks( /** * @param array $callbacks + * @param array $values * * @throws CollectionMismatchException * @throws UnableToFormatMessageException */ private function processTagElement( Parser\Type\TagElement $tagElement, - array $callbacks + array $callbacks, + array &$values ): Parser\Type\ElementCollection { if (!array_key_exists($tagElement->value, $callbacks)) { // We don't have a callback for this tag. @@ -190,7 +197,7 @@ private function processTagElement( $result = ($callbacks[$tagElement->value])(self::CALLBACK_REPLACEMENT); if (preg_match(self::CALLBACK_RESULT_PATTERN, $result, $matches)) { $start = new Parser\Type\LiteralElement($matches[1], $tagElement->location); - $middle = $this->processAstWithCallbacks($tagElement->children, $callbacks); + $middle = $this->processAst($tagElement->children, $callbacks, $values); $end = new Parser\Type\LiteralElement($matches[2], $tagElement->location); return new Parser\Type\ElementCollection([$start, ...array_values($middle->toArray()), $end]); @@ -199,6 +206,65 @@ private function processTagElement( return new Parser\Type\ElementCollection([new Parser\Type\LiteralElement($result, $tagElement->location)]); } + /** + * Performs special processing for number elements + * + * If the parameter is a percent-style number, then we multiply the value + * by 100. This is in keeping with the ECMA-402 draft, which specifies the + * `Intl.NumberFormat` rules. When using `Intl.NumberFormat` to format + * percentages, the number must first be multiplied by 100 before any + * formatting occurs. See section 15.1.6 of ECMA-402, specifically step 5.b. + * + * ECMA-402, however, doesn't define an API for MessageFormat, so FormatJS + * implements this on their own, using `Intl.NumberFormat` to process any + * number parameters it encounters. As a result, all number parameters in + * ICU message syntax that specify the `::percent` stem (i.e., + * "{0, number, ::percent}") have their values first multiplied by 100 + * before formatting them. + * + * This may not be considered a bug in FormatJS, since it is adhering to the + * ECMA-402 specification. However, it does not follow the rules for + * percentages as programmed in icu4c (the underlying library PHP uses), so + * in order to match the expected output of FormatJS, we multiply percent + * values by 100 before formatting them. + * + * Oddly enough, PHP's `NumberFormatter` has the same rules, and it uses + * the underlying ICU implementation of the number formatter: + * + * $nf = new NumberFormatter('en-US', NumberFormatter::PERCENT); + * echo $nf->format(25); // Produces "2,500%" + * + * While: + * + * $mf = new MessageFormatter('en-US', '{0, number, ::percent}'); + * echo $mf->format([25]); // Produces "25%" + * + * So, one could argue this is a bug in the ICU implementation of the + * percent number skeleton. + * + * @link https://tc39.es/ecma402/#sec-partitionnumberpattern + * @link https://formatjs.io/docs/core-concepts/icu-syntax/#number-type + * + * @param array $values + */ + private function processNumberElement( + Parser\Type\NumberElement $numberElement, + array &$values + ): Parser\Type\NumberElement { + if (!$numberElement->style instanceof Parser\Type\NumberSkeleton) { + return $numberElement; + } + + if ($numberElement->style->parsedOptions->style === NumberFormatOptions::STYLE_PERCENT) { + $key = $numberElement->value; + if (is_numeric($values[$key])) { + $values[$key] *= 100; + } + } + + return $numberElement; + } + /** * @param array $callbacks * diff --git a/tests/Intl/MessageFormatTest.php b/tests/Intl/MessageFormatTest.php index 4616298..d8c0845 100644 --- a/tests/Intl/MessageFormatTest.php +++ b/tests/Intl/MessageFormatTest.php @@ -326,4 +326,56 @@ public function testThrowsExceptionForIllegalArgumentError(): void ); } } + + public function testProcessesPercentagesAccordingToEcma402(): void + { + $message = 'Your discount is {discount, number, ::percent} off the retail value.'; + $expected = 'Your discount is 25% off the retail value.'; + + $locale = new Locale('en-US'); + $formatter = new MessageFormat($locale); + + $result = $formatter->format($message, ['discount' => 0.25]); + + $this->assertSame($expected, $result); + } + + public function testProcessesPercentagesAccordingToEcma402WithScaleAt100(): void + { + $message = 'Your discount is {discount, number, ::percent scale/100} off the retail value.'; + $expected = 'Your discount is 2,500% off the retail value.'; + + $locale = new Locale('en-US'); + $formatter = new MessageFormat($locale); + + $result = $formatter->format($message, ['discount' => 0.25]); + + $this->assertSame($expected, $result); + } + + public function testProcessesPercentagesAccordingToEcma402WithScaleAt1(): void + { + $message = 'Your discount is {discount, number, ::percent scale/1} off the retail value.'; + $expected = 'Your discount is 25% off the retail value.'; + + $locale = new Locale('en-US'); + $formatter = new MessageFormat($locale); + + $result = $formatter->format($message, ['discount' => 0.25]); + + $this->assertSame($expected, $result); + } + + public function testProcessesNumberWithoutStyle(): void + { + $message = 'Your discount is {discount, number} off the retail value.'; + $expected = 'Your discount is 25 off the retail value.'; + + $locale = new Locale('en-US'); + $formatter = new MessageFormat($locale); + + $result = $formatter->format($message, ['discount' => 25]); + + $this->assertSame($expected, $result); + } } From c847373323f137bd31dc58605d797d3f22664ebb Mon Sep 17 00:00:00 2001 From: Ben Ramsey Date: Fri, 4 Feb 2022 14:51:26 -0600 Subject: [PATCH 2/2] docs: update README to mention formatting percentages --- README.md | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 89adef2..30bdf48 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,31 @@ When formatting currency, you may use the following properties. If `notation` is `compact`, then you may specify the `compactDisplay` property with the value `short` or `long`. The default is `short`. +#### Formatting Percentages + +According to [ECMA-402, section 15.1.6](https://tc39.es/ecma402/#sec-partitionnumberpattern) +(specifically step 5.b.), if the style is "percent," then the number formatter +must multiply the value by 100. This means the formatter expects percent values +expressed as fractions of 100 (i.e., 0.25 for 25%, 0.055 for 5.5%, etc.). + +Since FormatJS also applies this rule to `::percent` number skeletons in +formatted messages, FormatPHP does, as well. + +For example: + +```php +echo $formatphp->formatMessage([ + 'id' => 'discountMessage', + 'defaultMessage' => 'You get {discount, number, ::percent} off the retail price!', +], [ + 'discount' => 0.25, +]); // e.g., "You get 25% off the retail price!" + +echo $formatphp->formatNumber(0.25, new Intl\NumberFormatOptions([ + 'style' => 'percent', +])); // e.g., "25%" +``` + ### Formatting Dates and Times You may use the methods `formatDate()` and `formatTime()` to format dates and @@ -341,16 +366,13 @@ echo $formatphp->formatMessage([ 'id' => 'priceMessage', 'defaultMessage' => <<<'EOD' Our price is {price} - with {discount} discount + with {discount, number, ::percent} discount EOD, ], [ 'price' => $formatphp->formatCurrency(29.99, 'USD', new Intl\NumberFormatOptions([ 'maximumFractionDigits' => 0, ])), - 'discount' => $formatphp->formatNumber(.025, new Intl\NumberFormatOptions([ - 'style' => 'percent', - 'minimumFractionDigits' => 1, - ])), + 'discount' => .025, 'boldThis' => fn ($text) => "$text", 'link' => fn ($text) => "$text", ]);