Skip to content

Commit 4255dfd

Browse files
authored
fix: multiply percentages by 100 internally to match FormatJS (#44)
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.[^1] 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.[^2] 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. [^1]: https://tc39.es/ecma402/#sec-partitionnumberpattern [^2]: https://formatjs.io/docs/core-concepts/icu-syntax/#number-type
1 parent bee646f commit 4255dfd

3 files changed

Lines changed: 160 additions & 20 deletions

File tree

README.md

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,31 @@ When formatting currency, you may use the following properties.
225225
If `notation` is `compact`, then you may specify the `compactDisplay` property
226226
with the value `short` or `long`. The default is `short`.
227227

228+
#### Formatting Percentages
229+
230+
According to [ECMA-402, section 15.1.6](https://tc39.es/ecma402/#sec-partitionnumberpattern)
231+
(specifically step 5.b.), if the style is "percent," then the number formatter
232+
must multiply the value by 100. This means the formatter expects percent values
233+
expressed as fractions of 100 (i.e., 0.25 for 25%, 0.055 for 5.5%, etc.).
234+
235+
Since FormatJS also applies this rule to `::percent` number skeletons in
236+
formatted messages, FormatPHP does, as well.
237+
238+
For example:
239+
240+
```php
241+
echo $formatphp->formatMessage([
242+
'id' => 'discountMessage',
243+
'defaultMessage' => 'You get {discount, number, ::percent} off the retail price!',
244+
], [
245+
'discount' => 0.25,
246+
]); // e.g., "You get 25% off the retail price!"
247+
248+
echo $formatphp->formatNumber(0.25, new Intl\NumberFormatOptions([
249+
'style' => 'percent',
250+
])); // e.g., "25%"
251+
```
252+
228253
### Formatting Dates and Times
229254

230255
You may use the methods `formatDate()` and `formatTime()` to format dates and
@@ -341,16 +366,13 @@ echo $formatphp->formatMessage([
341366
'id' => 'priceMessage',
342367
'defaultMessage' => <<<'EOD'
343368
Our price is <boldThis>{price}</boldThis>
344-
with <link>{discount} discount</link>
369+
with <link>{discount, number, ::percent} discount</link>
345370
EOD,
346371
], [
347372
'price' => $formatphp->formatCurrency(29.99, 'USD', new Intl\NumberFormatOptions([
348373
'maximumFractionDigits' => 0,
349374
])),
350-
'discount' => $formatphp->formatNumber(.025, new Intl\NumberFormatOptions([
351-
'style' => 'percent',
352-
'minimumFractionDigits' => 1,
353-
])),
375+
'discount' => .025,
354376
'boldThis' => fn ($text) => "<strong>$text</strong>",
355377
'link' => fn ($text) => "<a href=\"/discounts/1234\">$text</a>",
356378
]);

src/Intl/MessageFormat.php

Lines changed: 81 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
use function assert;
4141
use function is_callable;
4242
use function is_int;
43+
use function is_numeric;
4344
use function preg_match;
4445
use function sprintf;
4546

@@ -68,7 +69,7 @@ public function __construct(?LocaleInterface $locale = null)
6869
public function format(string $pattern, array $values = []): string
6970
{
7071
try {
71-
$pattern = $this->applyCallbacks($pattern, $values);
72+
$pattern = $this->applyPreprocessing($pattern, $values);
7273
$formatter = new PhpMessageFormatter((string) $this->locale->baseName(), $pattern);
7374

7475
$formattedMessage = $formatter->format($values);
@@ -107,21 +108,19 @@ public function format(string $pattern, array $values = []): string
107108
* @throws UnableToFormatMessageException
108109
* @throws CollectionMismatchException
109110
*/
110-
private function applyCallbacks(string $pattern, array &$values = []): string
111+
private function applyPreprocessing(string $pattern, array &$values = []): string
111112
{
112113
$callbacks = array_filter($values, fn ($value): bool => is_callable($value));
113114

114-
// If $values doesn't contain any callables, go ahead and return.
115-
if (!$callbacks) {
116-
return $pattern;
117-
}
118-
119115
// Remove the callbacks from the values, since we will use them below.
120116
foreach (array_keys($callbacks) as $key) {
121117
unset($values[$key]);
122118
}
123119

124-
$parser = new Parser($pattern);
120+
$parserOptions = new Parser\Options();
121+
$parserOptions->shouldParseSkeletons = true;
122+
123+
$parser = new Parser($pattern, $parserOptions);
125124
$parsed = $parser->parse();
126125

127126
if ($parsed->err !== null) {
@@ -130,18 +129,20 @@ private function applyCallbacks(string $pattern, array &$values = []): string
130129

131130
assert($parsed->val instanceof Parser\Type\ElementCollection);
132131

133-
return (new Printer())->printAst($this->processAstWithCallbacks($parsed->val, $callbacks));
132+
return (new Printer())->printAst($this->processAst($parsed->val, $callbacks, $values));
134133
}
135134

136135
/**
137136
* @param array<array-key, callable(string):string> $callbacks
137+
* @param array<array-key, float | int | string | callable(string):string> $values
138138
*
139139
* @throws CollectionMismatchException
140140
* @throws UnableToFormatMessageException
141141
*/
142-
private function processAstWithCallbacks(
142+
private function processAst(
143143
Parser\Type\ElementCollection $ast,
144-
array $callbacks
144+
array $callbacks,
145+
array &$values
145146
): Parser\Type\ElementCollection {
146147
$processedAst = new Parser\Type\ElementCollection();
147148

@@ -152,16 +153,20 @@ private function processAstWithCallbacks(
152153

153154
if ($clone instanceof PluralElement || $clone instanceof SelectElement) {
154155
foreach ($clone->options as $option) {
155-
$option->value = $this->processAstWithCallbacks($option->value, $callbacks);
156+
$option->value = $this->processAst($option->value, $callbacks, $values);
156157
}
157158
}
158159

159160
if ($clone instanceof Parser\Type\TagElement) {
160-
$processedAst = $processedAst->merge($this->processTagElement($clone, $callbacks));
161+
$processedAst = $processedAst->merge($this->processTagElement($clone, $callbacks, $values));
161162

162163
continue;
163164
}
164165

166+
if ($clone instanceof Parser\Type\NumberElement) {
167+
$clone = $this->processNumberElement($clone, $values);
168+
}
169+
165170
if ($clone instanceof Parser\Type\LiteralElement) {
166171
$clone = $this->processLiteralElement($clone, $callbacks);
167172
}
@@ -174,13 +179,15 @@ private function processAstWithCallbacks(
174179

175180
/**
176181
* @param array<array-key, callable(string):string> $callbacks
182+
* @param array<array-key, float | int | string | callable(string):string> $values
177183
*
178184
* @throws CollectionMismatchException
179185
* @throws UnableToFormatMessageException
180186
*/
181187
private function processTagElement(
182188
Parser\Type\TagElement $tagElement,
183-
array $callbacks
189+
array $callbacks,
190+
array &$values
184191
): Parser\Type\ElementCollection {
185192
if (!array_key_exists($tagElement->value, $callbacks)) {
186193
// We don't have a callback for this tag.
@@ -190,7 +197,7 @@ private function processTagElement(
190197
$result = ($callbacks[$tagElement->value])(self::CALLBACK_REPLACEMENT);
191198
if (preg_match(self::CALLBACK_RESULT_PATTERN, $result, $matches)) {
192199
$start = new Parser\Type\LiteralElement($matches[1], $tagElement->location);
193-
$middle = $this->processAstWithCallbacks($tagElement->children, $callbacks);
200+
$middle = $this->processAst($tagElement->children, $callbacks, $values);
194201
$end = new Parser\Type\LiteralElement($matches[2], $tagElement->location);
195202

196203
return new Parser\Type\ElementCollection([$start, ...array_values($middle->toArray()), $end]);
@@ -199,6 +206,65 @@ private function processTagElement(
199206
return new Parser\Type\ElementCollection([new Parser\Type\LiteralElement($result, $tagElement->location)]);
200207
}
201208

209+
/**
210+
* Performs special processing for number elements
211+
*
212+
* If the parameter is a percent-style number, then we multiply the value
213+
* by 100. This is in keeping with the ECMA-402 draft, which specifies the
214+
* `Intl.NumberFormat` rules. When using `Intl.NumberFormat` to format
215+
* percentages, the number must first be multiplied by 100 before any
216+
* formatting occurs. See section 15.1.6 of ECMA-402, specifically step 5.b.
217+
*
218+
* ECMA-402, however, doesn't define an API for MessageFormat, so FormatJS
219+
* implements this on their own, using `Intl.NumberFormat` to process any
220+
* number parameters it encounters. As a result, all number parameters in
221+
* ICU message syntax that specify the `::percent` stem (i.e.,
222+
* "{0, number, ::percent}") have their values first multiplied by 100
223+
* before formatting them.
224+
*
225+
* This may not be considered a bug in FormatJS, since it is adhering to the
226+
* ECMA-402 specification. However, it does not follow the rules for
227+
* percentages as programmed in icu4c (the underlying library PHP uses), so
228+
* in order to match the expected output of FormatJS, we multiply percent
229+
* values by 100 before formatting them.
230+
*
231+
* Oddly enough, PHP's `NumberFormatter` has the same rules, and it uses
232+
* the underlying ICU implementation of the number formatter:
233+
*
234+
* $nf = new NumberFormatter('en-US', NumberFormatter::PERCENT);
235+
* echo $nf->format(25); // Produces "2,500%"
236+
*
237+
* While:
238+
*
239+
* $mf = new MessageFormatter('en-US', '{0, number, ::percent}');
240+
* echo $mf->format([25]); // Produces "25%"
241+
*
242+
* So, one could argue this is a bug in the ICU implementation of the
243+
* percent number skeleton.
244+
*
245+
* @link https://tc39.es/ecma402/#sec-partitionnumberpattern
246+
* @link https://formatjs.io/docs/core-concepts/icu-syntax/#number-type
247+
*
248+
* @param array<array-key, float | int | string | callable(string):string> $values
249+
*/
250+
private function processNumberElement(
251+
Parser\Type\NumberElement $numberElement,
252+
array &$values
253+
): Parser\Type\NumberElement {
254+
if (!$numberElement->style instanceof Parser\Type\NumberSkeleton) {
255+
return $numberElement;
256+
}
257+
258+
if ($numberElement->style->parsedOptions->style === NumberFormatOptions::STYLE_PERCENT) {
259+
$key = $numberElement->value;
260+
if (is_numeric($values[$key])) {
261+
$values[$key] *= 100;
262+
}
263+
}
264+
265+
return $numberElement;
266+
}
267+
202268
/**
203269
* @param array<array-key, callable(string):string> $callbacks
204270
*

tests/Intl/MessageFormatTest.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,4 +326,56 @@ public function testThrowsExceptionForIllegalArgumentError(): void
326326
);
327327
}
328328
}
329+
330+
public function testProcessesPercentagesAccordingToEcma402(): void
331+
{
332+
$message = 'Your discount is {discount, number, ::percent} off the retail value.';
333+
$expected = 'Your discount is 25% off the retail value.';
334+
335+
$locale = new Locale('en-US');
336+
$formatter = new MessageFormat($locale);
337+
338+
$result = $formatter->format($message, ['discount' => 0.25]);
339+
340+
$this->assertSame($expected, $result);
341+
}
342+
343+
public function testProcessesPercentagesAccordingToEcma402WithScaleAt100(): void
344+
{
345+
$message = 'Your discount is {discount, number, ::percent scale/100} off the retail value.';
346+
$expected = 'Your discount is 2,500% off the retail value.';
347+
348+
$locale = new Locale('en-US');
349+
$formatter = new MessageFormat($locale);
350+
351+
$result = $formatter->format($message, ['discount' => 0.25]);
352+
353+
$this->assertSame($expected, $result);
354+
}
355+
356+
public function testProcessesPercentagesAccordingToEcma402WithScaleAt1(): void
357+
{
358+
$message = 'Your discount is {discount, number, ::percent scale/1} off the retail value.';
359+
$expected = 'Your discount is 25% off the retail value.';
360+
361+
$locale = new Locale('en-US');
362+
$formatter = new MessageFormat($locale);
363+
364+
$result = $formatter->format($message, ['discount' => 0.25]);
365+
366+
$this->assertSame($expected, $result);
367+
}
368+
369+
public function testProcessesNumberWithoutStyle(): void
370+
{
371+
$message = 'Your discount is {discount, number} off the retail value.';
372+
$expected = 'Your discount is 25 off the retail value.';
373+
374+
$locale = new Locale('en-US');
375+
$formatter = new MessageFormat($locale);
376+
377+
$result = $formatter->format($message, ['discount' => 25]);
378+
379+
$this->assertSame($expected, $result);
380+
}
329381
}

0 commit comments

Comments
 (0)