<?php
namespace CommerceGuys\Intl\Formatter;
use CommerceGuys\Intl\Currency\CurrencyInterface;
use CommerceGuys\Intl\Exception\InvalidArgumentException;
use CommerceGuys\Intl\NumberFormat\NumberFormatInterface;
/**
* Formats numbers using locale-specific patterns.
*/
class NumberFormatter implements NumberFormatterInterface
{
/**
* The number format.
*
* @var NumberFormatInterface
*/
protected $numberFormat;
/**
* The number pattern used to format positive numbers.
*
* @var string
*/
protected $positivePattern;
/**
* The number pattern used to format negative numbers.
*
* @var string
*/
protected $negativePattern;
/**
* Whether grouping is used.
*
* @var bool
*/
protected $groupingUsed;
/**
* The size of the group of digits closest to the decimal point.
*
* @var int
*/
protected $primaryGroupSize;
/**
* The size of every group of digits after the primary group.
*
* @var int
*/
protected $secondaryGroupSize;
/**
* The minimum number of fraction digits to show.
*
* @var int
*/
protected $minimumFractionDigits;
/**
* The maximum number of fraction digits to show.
*
* @var int
*/
protected $maximumFractionDigits;
/**
* The currency display style.
*
* @var int
*/
protected $currencyDisplay;
/**
* Localized digits.
*
* @var array
*/
protected $digits = array(
NumberFormatInterface::NUMBERING_SYSTEM_ARABIC => array(
0 => '٠', 1 => '١', 2 => '٢', 3 => '٣', 4 => '٤',
5 => '٥', 6 => '٦', 7 => '٧', 8 => '٨', 9 => '٩',
),
NumberFormatInterface::NUMBERING_SYSTEM_ARABIC_EXTENDED => array(
0 => '۰', 1 => '۱', 2 => '۲', 3 => '۳', 4 => '۴',
5 => '۵', 6 => '۶', 7 => '۷', 8 => '۸', 9 => '۹',
),
NumberFormatInterface::NUMBERING_SYSTEM_BENGALI => array(
0 => '০', 1 => '১', 2 => '২', 3 => '৩', 4 => '৪',
5 => '৫', 6 => '৬', 7 => '৭', 8 => '৮', 9 => '৯',
),
NumberFormatInterface::NUMBERING_SYSTEM_DEVANAGARI => array(
0 => '०', 1 => '१', 2 => '२', 3 => '३', 4 => '४',
5 => '५', 6 => '६', 7 => '७', 8 => '८', 9 => '९',
),
);
/**
* Creaes a NumberFormatter instance.
*
* @param NumberFormatInterface $numberFormat The number format.
* @param int $style The formatting style.
*
* @throws InvalidArgumentException
*/
public function __construct(NumberFormatInterface $numberFormat, $style = self::DECIMAL)
{
$availablePatterns = array(
self::DECIMAL => $numberFormat->getDecimalPattern(),
self::PERCENT => $numberFormat->getPercentPattern(),
self::CURRENCY => $numberFormat->getCurrencyPattern(),
self::CURRENCY_ACCOUNTING => $numberFormat->getAccountingCurrencyPattern(),
);
if (!array_key_exists($style, $availablePatterns)) {
// Unknown type.
throw new InvalidArgumentException('Unknown format style provided to NumberFormatter::__construct().');
}
// Split the selected pattern into positive and negative patterns.
$patterns = explode(';', $availablePatterns[$style]);
if (!isset($patterns[1])) {
// No explicit negative pattern was provided, construct it.
$patterns[1] = '-' . $patterns[0];
}
$this->numberFormat = $numberFormat;
$this->positivePattern = $patterns[0];
$this->negativePattern = $patterns[1];
$this->groupingUsed = (strpos($this->positivePattern, ',') !== false);
// This pattern has number groups, parse them.
if ($this->groupingUsed) {
preg_match('/#+0/', $this->positivePattern, $primaryGroupMatches);
$this->primaryGroupSize = $this->secondaryGroupSize = strlen($primaryGroupMatches[0]);
$numberGroups = explode(',', $this->positivePattern);
if (count($numberGroups) > 2) {
// This pattern has a distinct secondary group size.
$this->secondaryGroupSize = strlen($numberGroups[1]);
}
}
// Initialize the fraction digit settings for decimal and percent
// styles only. The currency ones will default to the currency values.
if (in_array($style, array(self::DECIMAL, self::PERCENT))) {
$this->minimumFractionDigits = 0;
$this->maximumFractionDigits = 3;
}
$this->currencyDisplay = self::CURRENCY_DISPLAY_SYMBOL;
}
/**
* {@inheritdoc}
*/
public function format($value)
{
if (!is_numeric($value)) {
$message = sprintf('The provided value "%s" must be a valid number or numeric string.', $value);
throw new InvalidArgumentException($message);
}
// Ensure that the value is positive and has the right number of digits.
$negative = (bccomp('0', $value, 12) == 1);
$signMultiplier = $negative ? '-1' : '1';
$value = bcdiv($value, $signMultiplier, $this->maximumFractionDigits);
// Split the number into major and minor digits.
$valueParts = explode('.', $value);
$majorDigits = $valueParts[0];
// Account for maximumFractionDigits = 0, where the number won't
// have a decimal point, and $valueParts[1] won't be set.
$minorDigits = isset($valueParts[1]) ? $valueParts[1] : '';
if ($this->groupingUsed) {
// Reverse the major digits, since they are grouped from the right.
$majorDigits = array_reverse(str_split($majorDigits));
// Group the major digits.
$groups = array();
$groups[] = array_splice($majorDigits, 0, $this->primaryGroupSize);
while (!empty($majorDigits)) {
$groups[] = array_splice($majorDigits, 0, $this->secondaryGroupSize);
}
// Reverse the groups and the digits inside of them.
$groups = array_reverse($groups);
foreach ($groups as &$group) {
$group = implode(array_reverse($group));
}
// Reconstruct the major digits.
$majorDigits = implode(',', $groups);
}
if ($this->minimumFractionDigits < $this->maximumFractionDigits) {
// Strip any trailing zeroes.
$minorDigits = rtrim($minorDigits, '0');
if (strlen($minorDigits) < $this->minimumFractionDigits) {
// Now there are too few digits, re-add trailing zeroes
// until the desired length is reached.
$neededZeroes = $this->minimumFractionDigits - strlen($minorDigits);
$minorDigits .= str_repeat('0', $neededZeroes);
}
}
// Assemble the final number and insert it into the pattern.
$value = $minorDigits ? $majorDigits . '.' . $minorDigits : $majorDigits;
$pattern = $negative ? $this->negativePattern : $this->positivePattern;
$value = preg_replace('/#(?:[\.,]#+)*0(?:[,\.][0#]+)*/', $value, $pattern);
// Localize the number.
$value = $this->replaceDigits($value);
$value = $this->replaceSymbols($value);
return $value;
}
/**
* {@inheritdoc}
*/
public function formatCurrency($value, CurrencyInterface $currency)
{
// Use the currency defaults if the values weren't set by the caller.
$resetMinimumFractionDigits = $resetMaximumFractionDigits = false;
if (!isset($this->minimumFractionDigits)) {
$this->minimumFractionDigits = $currency->getFractionDigits();
$resetMinimumFractionDigits = true;
}
if (!isset($this->maximumFractionDigits)) {
$this->maximumFractionDigits = $currency->getFractionDigits();
$resetMaximumFractionDigits = true;
}
// Format the decimal part of the value first.
$value = $this->format($value);
// Reset the fraction digit settings, so that they don't affect
// future formattings with different currencies.
if ($resetMinimumFractionDigits) {
$this->minimumFractionDigits = null;
}
if ($resetMaximumFractionDigits) {
$this->maximumFractionDigits = null;
}
// Determine whether to show the currency symbol or the currency code.
if ($this->currencyDisplay == self::CURRENCY_DISPLAY_SYMBOL) {
$symbol = $currency->getSymbol();
} else {
$symbol = $currency->getCurrencyCode();
}
return str_replace('¤', $symbol, $value);
}
/**
* {@inheritdoc}
*/
public function parseCurrency($value, CurrencyInterface $currency)
{
$replacements = array(
// Convert the localized symbols back to their original form.
$this->numberFormat->getDecimalSeparator() => '.',
$this->numberFormat->getPlusSign() => '+',
$this->numberFormat->getMinusSign() => '-',
// Strip any grouping separators, the currency code or symbol.
$this->numberFormat->getGroupingSeparator() => '',
$currency->getCurrencyCode() => '',
$currency->getSymbol() => '',
// Strip whitespace (spaces and non-breaking spaces).
' ' => '',
chr(0xC2) . chr(0xA0) => '',
);
$numberingSystem = $this->numberFormat->getNumberingSystem();
if (isset($this->digits[$numberingSystem])) {
// Convert the localized digits back to latin.
$replacements += array_flip($this->digits[$numberingSystem]);
}
$value = strtr($value, $replacements);
if (substr($value, 0, 1) == '(' && substr($value, -1, 1) == ')') {
// This is an accounting formatted negative number.
$value = '-' . str_replace(array('(', ')'), '', $value);
}
return is_numeric($value) ? $value : false;
}
/**
* Replaces digits with their localized equivalents.
*
* @param string $value The value being formatted.
*
* @return string
*/
protected function replaceDigits($value)
{
$numberingSystem = $this->numberFormat->getNumberingSystem();
if (isset($this->digits[$numberingSystem])) {
$value = strtr($value, $this->digits[$numberingSystem]);
}
return $value;
}
/**
* Replaces number symbols with their localized equivalents.
*
* @param string $value The value being formatted.
*
* @return string
*
* @see http://cldr.unicode.org/translation/number-symbols
*/
protected function replaceSymbols($value)
{
$replacements = array(
'.' => $this->numberFormat->getDecimalSeparator(),
',' => $this->numberFormat->getGroupingSeparator(),
'+' => $this->numberFormat->getPlusSign(),
'-' => $this->numberFormat->getMinusSign(),
'%' => $this->numberFormat->getPercentSign(),
);
return strtr($value, $replacements);
}
/**
* {@inheritdoc}
*/
public function getNumberFormat()
{
return $this->numberFormat;
}
/**
* {@inheritdoc}
*/
public function getMinimumFractionDigits()
{
return $this->minimumFractionDigits;
}
/**
* {@inheritdoc}
*/
public function setMinimumFractionDigits($minimumFractionDigits)
{
$this->minimumFractionDigits = $minimumFractionDigits;
return $this;
}
/**
* {@inheritdoc}
*/
public function getMaximumFractionDigits()
{
return $this->maximumFractionDigits;
}
/**
* {@inheritdoc}
*/
public function setMaximumFractionDigits($maximumFractionDigits)
{
$this->maximumFractionDigits = $maximumFractionDigits;
return $this;
}
/**
* {@inheritdoc}
*/
public function isGroupingUsed()
{
return $this->groupingUsed;
}
/**
* {@inheritdoc}
*/
public function setGroupingUsed($groupingUsed)
{
$this->groupingUsed = $groupingUsed;
return $this;
}
/**
* {@inheritdoc}
*/
public function getCurrencyDisplay()
{
return $this->currencyDisplay;
}
/**
* {@inheritdoc}
*/
public function setCurrencyDisplay($currencyDisplay)
{
$this->currencyDisplay = $currencyDisplay;
return $this;
}
}