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",
]);
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);
+ }
}