diff --git a/src/Filter/NumberFormat.php b/src/Filter/NumberFormat.php index 68e363c1..8b7ebedb 100644 --- a/src/Filter/NumberFormat.php +++ b/src/Filter/NumberFormat.php @@ -27,23 +27,18 @@ public function filter($value) return $value; } - if (!is_int($value) - && !is_float($value) - ) { + if (!is_int($value) && !is_float($value)) { $result = parent::filter($value); } else { ErrorHandler::start(); - $result = $this->getFormatter()->format( - $value, - $this->getType() - ); + $result = $this->getFormatter()->format($value, $this->getType()); ErrorHandler::stop(); } if (false !== $result) { - return str_replace("\xC2\xA0", ' ', $result); + return $result; } return $value; diff --git a/src/Validator/Float.php b/src/Validator/Float.php index d1026caa..af146089 100644 --- a/src/Validator/Float.php +++ b/src/Validator/Float.php @@ -14,6 +14,8 @@ use Traversable; use Zend\I18n\Exception as I18nException; use Zend\Stdlib\ArrayUtils; +use Zend\Stdlib\StringUtils; +use Zend\Stdlib\StringWrapper\StringWrapperInterface; use Zend\Validator\AbstractValidator; use Zend\Validator\Exception; @@ -37,6 +39,13 @@ class Float extends AbstractValidator */ protected $locale; + /** + * UTF-8 compatable wrapper for string functions + * + * @var StringWrapperInterface + */ + protected $wrapper; + /** * Constructor for the integer validator * @@ -46,12 +55,13 @@ class Float extends AbstractValidator public function __construct($options = array()) { if (!extension_loaded('intl')) { - throw new I18nException\ExtensionNotLoadedException(sprintf( - '%s component requires the intl PHP extension', - __NAMESPACE__ - )); + throw new I18nException\ExtensionNotLoadedException( + sprintf('%s component requires the intl PHP extension', __NAMESPACE__) + ); } + $this->wrapper = StringUtils::getWrapper(); + if ($options instanceof Traversable) { $options = ArrayUtils::iteratorToArray($options); } @@ -88,9 +98,9 @@ public function setLocale($locale) return $this; } - /** - * Returns true if and only if $value is a floating-point value + * Returns true if and only if $value is a floating-point value. Uses the formal definition of a float as described + * in the PHP manual: {@link http://www.php.net/float} * * @param string $value * @return bool @@ -98,46 +108,135 @@ public function setLocale($locale) */ public function isValid($value) { - if (!is_string($value) && !is_int($value) && !is_float($value)) { + if (!is_scalar($value) || is_bool($value)) { $this->error(self::INVALID); return false; } $this->setValue($value); - if (is_float($value)) { + if (is_float($value) || is_int($value)) { return true; } - $locale = $this->getLocale(); - $format = new NumberFormatter($locale, NumberFormatter::DECIMAL); - if (intl_is_failure($format->getErrorCode())) { - throw new Exception\InvalidArgumentException("Invalid locale string given"); + // Need to check if this is scientific formatted string. If not, switch to decimal. + $formatter = new NumberFormatter($this->getLocale(), NumberFormatter::SCIENTIFIC); + + if (intl_is_failure($formatter->getErrorCode())) { + throw new Exception\InvalidArgumentException($formatter->getErrorMessage()); } - $parsedFloat = $format->parse($value, NumberFormatter::TYPE_DOUBLE); - if (intl_is_failure($format->getErrorCode())) { - $this->error(self::NOT_FLOAT); - return false; + if (StringUtils::hasPcreUnicodeSupport()) { + $exponentialSymbols = '[Ee' . $formatter->getSymbol(NumberFormatter::EXPONENTIAL_SYMBOL) . ']+'; + $search = '/' . $exponentialSymbols . '/u'; + } else { + $exponentialSymbols = '[Ee]'; + $search = '/' . $exponentialSymbols . '/'; } - $decimalSep = $format->getSymbol(NumberFormatter::DECIMAL_SEPARATOR_SYMBOL); - $groupingSep = $format->getSymbol(NumberFormatter::GROUPING_SEPARATOR_SYMBOL); + if (!preg_match($search, $value)) { + $formatter = new NumberFormatter($this->getLocale(), NumberFormatter::DECIMAL); + } - $valueFiltered = str_replace($groupingSep, '', $value); - $valueFiltered = str_replace($decimalSep, '.', $valueFiltered); + /** + * @desc There are seperator "look-alikes" for decimal and group seperators that are more commonly used than the + * official unicode chracter. We need to replace those with the real thing - or remove it. + */ + $groupSeparator = $formatter->getSymbol(NumberFormatter::GROUPING_SEPARATOR_SYMBOL); + $decSeparator = $formatter->getSymbol(NumberFormatter::DECIMAL_SEPARATOR_SYMBOL); + + //NO-BREAK SPACE and ARABIC THOUSANDS SEPARATOR + if ($groupSeparator == "\xC2\xA0") { + $value = str_replace(' ', $groupSeparator, $value); + } elseif ($groupSeparator == "\xD9\xAC") { //NumberFormatter doesn't have grouping at all for Arabic-Indic + $value = str_replace(array('\'', $groupSeparator), '', $value); + } - while (strpos($valueFiltered, '.') !== false - && (substr($valueFiltered, -1) == '0' || substr($valueFiltered, -1) == '.') - ) { - $valueFiltered = substr($valueFiltered, 0, strlen($valueFiltered) - 1); + //ARABIC DECIMAL SEPARATOR + if ($decSeparator == "\xD9\xAB") { + $value = str_replace(',', $decSeparator, $value); } - if (strval($parsedFloat) !== $valueFiltered) { - $this->error(self::NOT_FLOAT); + $groupSeparatorPosition = $this->wrapper->strpos($value, $groupSeparator); + $decSeparatorPosition = $this->wrapper->strpos($value, $decSeparator); + + //We have seperators, and they are flipped. i.e. 2.000,000 for en-US + if ($groupSeparatorPosition && $decSeparatorPosition && $groupSeparatorPosition > $decSeparatorPosition) { return false; } - return true; + //If we have Unicode support, we can use the real graphemes, otherwise, just the ASCII characters + $decimal = '['. preg_quote($decSeparator, '/') . ']'; + $prefix = '[+-]'; + $exp = $exponentialSymbols; + $numberRange = '0-9'; + $useUnicode = ''; + $suffix = ''; + + if (StringUtils::hasPcreUnicodeSupport()) { + $prefix = '[' + . preg_quote( + $formatter->getTextAttribute(NumberFormatter::POSITIVE_PREFIX) + . $formatter->getTextAttribute(NumberFormatter::NEGATIVE_PREFIX) + . $formatter->getSymbol(NumberFormatter::PLUS_SIGN_SYMBOL) + . $formatter->getSymbol(NumberFormatter::MINUS_SIGN_SYMBOL), + '/' + ) + . ']{0,3}'; + $suffix = ($formatter->getTextAttribute(NumberFormatter::NEGATIVE_SUFFIX)) + ? '[' + . preg_quote( + $formatter->getTextAttribute(NumberFormatter::POSITIVE_SUFFIX) + . $formatter->getTextAttribute(NumberFormatter::NEGATIVE_SUFFIX) + . $formatter->getSymbol(NumberFormatter::PLUS_SIGN_SYMBOL) + . $formatter->getSymbol(NumberFormatter::MINUS_SIGN_SYMBOL), + '/' + ) + . ']{0,3}' + : ''; + $numberRange = '\p{N}'; + $useUnicode = 'u'; + } + + /** + * @desc Match against the formal definition of a float. The + * exponential number check is modified for RTL non-Latin number + * systems (Arabic-Indic numbering). I'm also switching out the period + * for the decimal separator. The formal definition leaves out +- from + * the integer and decimal notations so add that. This also checks + * that a grouping sperator is not in the last GROUPING_SIZE graphemes + * of the string - i.e. 10,6 is not valid for en-US. + * @see http://www.php.net/float + */ + + $lnum = '[' . $numberRange . ']+'; + $dnum = '(([' . $numberRange . ']*' . $decimal . $lnum . ')|(' . $lnum . $decimal . '[' . $numberRange . ']*))'; + $expDnum = '((' . $prefix . '((' . $lnum . '|' . $dnum . ')' . $exp . $prefix . $lnum . ')' . $suffix . ')|' + . '(' . $suffix . '(' . $lnum . $prefix . $exp . '(' . $dnum . '|' . $lnum . '))' . $prefix . '))'; + + // LEFT-TO-RIGHT MARK (U+200E) is messing up everything for the handful + // of locales that have it + $lnumSearch = str_replace("\xE2\x80\x8E", '', '/^' .$prefix . $lnum . $suffix . '$/' . $useUnicode); + $dnumSearch = str_replace("\xE2\x80\x8E", '', '/^' .$prefix . $dnum . $suffix . '$/' . $useUnicode); + $expDnumSearch = str_replace("\xE2\x80\x8E", '', '/^' . $expDnum . '$/' . $useUnicode); + $value = str_replace("\xE2\x80\x8E", '', $value); + $unGroupedValue = str_replace($groupSeparator, '', $value); + + // No strrpos() in wrappers yet. ICU 4.x doesn't have grouping size for + // everything. ICU 52 has 3 for ALL locales. + $groupSize = ($formatter->getAttribute(NumberFormatter::GROUPING_SIZE)) + ? $formatter->getAttribute(NumberFormatter::GROUPING_SIZE) + : 3; + $lastStringGroup = $this->wrapper->substr($value, -$groupSize); + + if ((preg_match($lnumSearch, $unGroupedValue) + || preg_match($dnumSearch, $unGroupedValue) + || preg_match($expDnumSearch, $unGroupedValue)) + && false === $this->wrapper->strpos($lastStringGroup, $groupSeparator) + ) { + return true; + } + + return false; } } diff --git a/test/Filter/NumberFormatTest.php b/test/Filter/NumberFormatTest.php index 1f3d378f..a2be3bb1 100644 --- a/test/Filter/NumberFormatTest.php +++ b/test/Filter/NumberFormatTest.php @@ -100,7 +100,7 @@ public function numberToFormattedProvider() NumberFormatter::DEFAULT_STYLE, NumberFormatter::TYPE_DOUBLE, 1234567.8912346, - '1 234 567,891' + '1 234 567,891' ), ); } @@ -161,6 +161,6 @@ public function testReturnUnfiltered($input) { $filter = new NumberFormatFilter('de_AT', NumberFormatter::DEFAULT_STYLE, NumberFormatter::TYPE_DOUBLE); - $this->assertEquals($input, $filter->filter($input)); + $this->assertEquals($input, $filter->filter($input)); } } diff --git a/test/Validator/FloatTest.php b/test/Validator/FloatTest.php index 995afedf..d418a76a 100644 --- a/test/Validator/FloatTest.php +++ b/test/Validator/FloatTest.php @@ -11,6 +11,7 @@ use Zend\I18n\Validator\Float as FloatValidator; use Locale; +use NumberFormatter; /** * @group Zend_Validator @@ -22,7 +23,9 @@ class FloatTest extends \PHPUnit_Framework_TestCase */ protected $validator; - /** @var string */ + /** + * @var string + */ protected $locale; public function setUp() @@ -43,165 +46,178 @@ public function tearDown() } /** - * Ensures that the validator follows expected behavior + * Test float and interger type variables. Includes decimal and scientific notation NumberFormatter-formatted + * versions. Should return true for all locales. * - * @dataProvider basicProvider + * @param string $value that will be tested + * @param boolean $expected expected result of assertion + * @param string $locale locale for validation + * @dataProvider floatAndIntegerProvider * @return void */ - public function testBasic($value, $expected) + public function testFloatAndIntegers($value, $expected, $locale, $type) { - $this->assertEquals($expected, $this->validator->isValid($value), - 'Failed expecting ' . $value . ' being ' . ($expected ? 'true' : 'false')); - } + $this->validator->setLocale($locale); - public function basicProvider() - { - return array( - array(1.00, true), - array(0.01, true), - array(-0.1, true), - array('10.1', true), - array('5.00', true), - array('10.0', true), - array('10.10', true), - array(1, true), - array('10.1not a float', false), + $this->assertEquals( + $expected, + $this->validator->isValid($value), + 'Failed expecting ' . $value . ' being ' . ($expected ? 'true' : 'false') . + sprintf(" (locale:%s, type:%s)", $locale, $type) . ', ICU Version:' . INTL_ICU_VERSION . '-' . + INTL_ICU_DATA_VERSION ); } - /** - * Ensures that getMessages() returns expected default value - * - * @return void - */ - public function testGetMessages() - { - $this->assertEquals(array(), $this->validator->getMessages()); - } - /** - * Ensures that set/getLocale() works - */ - public function testSettingLocales() - { - $this->validator->setLocale('de'); - $this->assertEquals('de', $this->validator->getLocale()); - $this->assertEquals(true, $this->validator->isValid('10,5')); + public function floatAndIntegerProvider() + { + $trueArray = array(); + $testingLocales = array('ar', 'bn', 'de', 'dz', 'en', 'fr-CH', 'ja', 'ks', 'ml-IN', 'mr', 'my', 'ps', 'ru'); + $testingExamples = array(1000, -2000, +398.00, 0.04, -0.5, .6, -.70, 8E10, -9.3456E-2, 10.23E6, + 123.1234567890987654321); + + //Loop locales and examples for a more thorough set of "true" test data + foreach ($testingLocales as $locale) { + foreach ($testingExamples as $example) { + $trueArray[] = array($example, true, $locale, 'raw'); + //Decimal Formatted + $trueArray[] = array( + NumberFormatter::create($locale, NumberFormatter::DECIMAL) + ->format($example, NumberFormatter::TYPE_DOUBLE), + true, + $locale, + 'decimal' + ); + //Scientific Notation Formatted + $trueArray[] = array( + NumberFormatter::create($locale, NumberFormatter::SCIENTIFIC) + ->format($example, NumberFormatter::TYPE_DOUBLE), + true, + $locale, + 'scientific' + ); + } + } + return $trueArray; } /** - * @ZF-4352 + * Test manually-generated strings for specific locales. These are "look-alike" strings where graphemes such as + * NO-BREAK SPACE, ARABIC THOUSANDS SEPARATOR, and ARABIC DECIMAL SEPARATOR are replaced with more typical ASCII + * characters. + * + * @param string $value that will be tested + * @param boolean $expected expected result of assertion + * @param string $locale locale for validation + * @dataProvider lookAlikeProvider + * @return void */ - public function testNonStringValidation() + public function testlookAlikes($value, $expected, $locale) { - $this->assertFalse($this->validator->isValid(array(1 => 1))); - } + $this->validator->setLocale($locale); - /** - * @ZF-7489 - */ - public function testUsingApplicationLocale() - { - Locale::setDefault('de'); - $valid = new FloatValidator(); - $this->assertTrue($valid->isValid(123,456)); - $this->assertTrue($valid->isValid('123,456')); + $this->assertEquals( + $expected, + $this->validator->isValid($value), + 'Failed expecting ' . $value . ' being ' . ($expected ? 'true' : 'false') . sprintf(" (locale:%s)", $locale) + ); } - /** - * @ZF-7987 - */ - public function testLocaleDeFloatType() + public function lookAlikeProvider() { - $this->validator->setLocale('de'); - $this->assertEquals('de', $this->validator->getLocale()); - $this->assertEquals(true, $this->validator->isValid(10.5)); - } + $trueArray = array(); + $testingArray = array( + 'ar' => "\xD9\xA1'\xD9\xA1\xD9\xA1\xD9\xA1,\xD9\xA2\xD9\xA3", + 'ru' => '2 000,00' + ); - /** - * @ZF-7987 - */ - public function testPhpLocaleDeFloatType() - { - Locale::setDefault('de'); - $valid = new FloatValidator(); - $this->assertTrue($valid->isValid(10.5)); + //Loop locales and examples for a more thorough set of "true" test data + foreach ($testingArray as $locale => $example) { + $trueArray[] = array($example, true, $locale); + } + return $trueArray; } /** - * @ZF-7987 + * Test manually-generated strings for specific locales. These are "look-alike" strings where graphemes such as + * NO-BREAK SPACE, ARABIC THOUSANDS SEPARATOR, and ARABIC DECIMAL SEPARATOR are replaced with more typical ASCII + * characters. + * + * @param string $value that will be tested + * @param boolean $expected expected result of assertion + * @param string $locale locale for validation + * @dataProvider validationFailureProvider + * @return void */ - public function testPhpLocaleFrFloatType() + public function testValidationFailures($value, $expected, $locale) { - Locale::setDefault('fr'); - $valid = new FloatValidator(); - $this->assertTrue($valid->isValid(10.5)); + $this->validator->setLocale($locale); + + $this->assertEquals( + $expected, + $this->validator->isValid($value), + 'Failed expecting ' . $value . ' being ' . ($expected ? 'true' : 'false') . sprintf(" (locale:%s)", $locale) + ); } - public function deLocaleStringsProvider() + public function validationFailureProvider() { - return array( - array('1,3', true), - array('1000,3', true), - array('1.000,3', true), + $trueArray = array(); + $testingArray = array( + 'ar' => array('10.1', '66notflot.6'), + 'ru' => array('10.1', '66notflot.6', '2,000.00', '2 00'), + 'en' => array('10,1', '66notflot.6', '2.000,00', '2 000', '2,00'), + 'fr-CH' => array('10,1', '66notflot.6', '2,000.00', '2 000', "2'00") ); + + //Loop locales and examples for a more thorough set of "true" test data + foreach ($testingArray as $locale => $exampleArray) { + foreach ($exampleArray as $example) { + $trueArray[] = array($example, false, $locale); + } + } + return $trueArray; } /** - * @ZF-8919 - * @dataProvider deLocaleStringsProvider + * Ensures that getMessages() returns expected default value + * + * @return void */ - public function testPhpLocaleDeStringType($float, $expected) - { - Locale::setDefault('de_AT'); - $valid = new FloatValidator(array('locale' => 'de_AT')); - $this->assertEquals($expected, $valid->isValid($float)); - } - - public function frLocaleStringsProvider() + public function testGetMessages() { - return array( - array('1,3', true), - array('1000,3', true), - array('1 000,3', true), - array('1.3', false), - array('1000.3', false), - array('1,000.3', false), - ); + $this->assertEquals(array(), $this->validator->getMessages()); } /** - * @ZF-8919 - * @dataProvider frLocaleStringsProvider + * Ensures that set/getLocale() works */ - public function testPhpLocaleFrStringType($float, $expected) + public function testSettingLocales() { - $valid = new FloatValidator(array('locale' => 'fr_FR')); - $this->assertEquals($expected, $valid->isValid($float)); + $this->validator->setLocale('de'); + $this->assertEquals('de', $this->validator->getLocale()); } - public function enLocaleStringsProvider() + /** + * @ZF-4352 + */ + public function testNonStringValidation() { - return array( - array('1.3', true), - array('1000.3', true), - array('1,000.3', true), - ); + $this->assertFalse($this->validator->isValid(array(1 => 1))); } /** - * @ZF-8919 - * @dataProvider enLocaleStringsProvider + * @ZF-7489 */ - public function testPhpLocaleEnStringType($float, $expected) + public function testUsingApplicationLocale() { - $valid = new FloatValidator(array('locale' => 'en_US')); - $this->assertEquals($expected, $valid->isValid($float)); + Locale::setDefault('de'); + $valid = new FloatValidator(); + $this->assertEquals('de', $valid->getLocale()); } public function testEqualsMessageTemplates() { $validator = $this->validator; - $this->assertAttributeEquals($validator->getOption('messageTemplates'), - 'messageTemplates', $validator); + $this->assertAttributeEquals($validator->getOption('messageTemplates'), 'messageTemplates', $validator); } }