Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -341,16 +366,13 @@ echo $formatphp->formatMessage([
'id' => 'priceMessage',
'defaultMessage' => <<<'EOD'
Our price is <boldThis>{price}</boldThis>
with <link>{discount} discount</link>
with <link>{discount, number, ::percent} discount</link>
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) => "<strong>$text</strong>",
'link' => fn ($text) => "<a href=\"/discounts/1234\">$text</a>",
]);
Expand Down
96 changes: 81 additions & 15 deletions src/Intl/MessageFormat.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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<array-key, callable(string):string> $callbacks
* @param array<array-key, float | int | string | callable(string):string> $values
*
* @throws CollectionMismatchException
* @throws UnableToFormatMessageException
*/
private function processAstWithCallbacks(
private function processAst(
Comment thread
ramsey marked this conversation as resolved.
Parser\Type\ElementCollection $ast,
array $callbacks
array $callbacks,
array &$values
): Parser\Type\ElementCollection {
$processedAst = new Parser\Type\ElementCollection();

Expand All @@ -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);
}
Expand All @@ -174,13 +179,15 @@ private function processAstWithCallbacks(

/**
* @param array<array-key, callable(string):string> $callbacks
* @param array<array-key, float | int | string | callable(string):string> $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.
Expand All @@ -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]);
Expand All @@ -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<array-key, float | int | string | callable(string):string> $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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💯

}
}

return $numberElement;
}

/**
* @param array<array-key, callable(string):string> $callbacks
*
Expand Down
52 changes: 52 additions & 0 deletions tests/Intl/MessageFormatTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}