Monday, April 10, 2017

How much is that doggie in the window

(How Much Is) That Doggie in the Window?
How much is that doggie in the window?
The one with the waggly tail
How much is that doggie in the window?
I do hope that doggie's for sale

Patti Page

One of the problems you're likely to face when dealing with user input of currency in an international market is the difference between number formats and the ambiguity it may introduce.

If we ignore currencies that don't allow fractional values (like the Yen), there's a big difference when trying to convert either "123,456,789.01" and "123.456.789,01" even though when a human looks at them we can tell they're the same.

For UI engineers, you'll likely use either parseInt or parseFloat - unless you give up entirely and pass the value unmodified. This poses a problem, because although there is a method to convert a number to a string that considers locale, the functions that convert strings to numbers do not.

So, what are we to do? First, let's look at what's native in JavaScript to help us find the underlying assumptions. The parseInt method is pretty self-explanatory, aside from what happens on corner cases where you're trying to convert without a radix or the like - and the parseFloat method is only slightly less self-explanatory.

The parseFloat method does not consider locale, even though the methods provided to convert a floating point to a string do. If, for example, a comma is used as a fractional separator, everything to the left of the comma will be taken as an integer. If the user - let's say one who lives in Germany - enters a number as 1.234,56 then our friendly method will convert that to 1 and 234 thousandths. If you're expecting one thousand two hundred thirty four and fifty-six hundredths, getting one and twenty-three hundredths is probably going to be disappointing. If the user - let's say one who lives in the US - enters a number as 1,234.56 then our friendly method will convert that to one (you won't even get the 234 thousandths).

By looking at the behavior of parseFloat, we learn that a dot is always interpreted as a fractional separator. We (UI engineers) just need to determine what to do when there are multiple separators and, more importantly, both types of separators (comma and dot) in a number string.

When writing numbers using separators, the rightmost separator is the fractional separator - everything else is a grouping separator - unless there are only grouping separators. How do we know if the separator is a grouping separator and not a fractional separator? We have to make an educated guess, based on the number of "groups".

Following these assumptions makes our enhanced parsing much simpler than what we might have thought at the beginning. All we need to do is split the string on the fractional separator, reconstruct the string in a format parseFloat always understands, and let parseFloat do the heavy lifting - a little task that can easily be handled by creative use of the indexOf method, giving us something like the code below.

JavaScript
function parseLocalizedFloat(numstring) {   var comma = numstring.lastIndexOf(','),       dot = numstring.lastIndexOf('.'),       sp = numstring.lastIndexOf(' '),       rightmost = Math.max(comma, dot, sp),       seps = /[\s\,\.]/g,       grps = numstring.split(seps).length,       normalized = numstring;    /* if there is a group separator and a decimal separator */    if ((comma > -1 && dot > -1) ||        (comma > -1 && sp > -1) ||        (dot > -1 && sp > -1)) {      normalized = numstring.substr(0, rightmost).replace(seps, '') +          '.' +          numstring.substr(rightmost).replace(seps, '');    /* if there are only group separators */    } else if (grps > 2) {      normalized = numstring.replace(seps, '');    /* if there is only one separator, assume it's a decimal separator */    } else if (grps === 2) {      normalized = numstring.replace(seps, '.');    }    return parseFloat(normalized); }

Note that if you cannot use lastIndexOf, you'll need to reverse the string, use indexOf and subtract that from the length (and don't forget to subtract 1 because it's a zero-based index). Lines 2-7 change to those show below, but everything beyond the variable declaration and assignment block will remain the same.
JavaScript
var reversed = numstring.split('').reverse().join(''),     comma = Math.max(reversed.length - reversed.indexOf(','), 0) - 1,     dot = Math.max(reversed.length - reversed.indexOf('.'), 0) - 1,     sp = Math.max(reversed.length - reversed.indexOf('.'), 0) - 1,     rightmost = Math.max(comma, dot, sp),     seps = /[\s\,\.]/g,     grps = numstring.split(seps).length,     normalized = numstring;


This method will accurately parse arabic numerals for the primary locale-based formats (shown below) much more reliably than parseInt or parseFloat alone. As a demo, click on any of the amounts and a JavaScript alert message will appear that will show you the string as it is in the table cell, the value you would get if you used parseFloat, and the value my internationalized version returns.


Number format examples for multiple locales
LocaleExample
Danish4 294 967 295,123
English (CA)4 294 967 295,123
English (GB)4,294,967,295.12
English (US)4,294,967,295.12
Finnish4 294 967 295,123
French4 294 967 295,123
French (CA)4 294 967 295,123
German4 294 967.295,123
Italian4.294.967.295,123
Norwegian4.294.967.295,123
Spanish4.294.967.295,123
Swedish4 294 967 295,123
Thai 4,294,967,295.12


Update

If you are coding specifically for next generation browsers, you can use the JavaScript Intl object to simplify your code, assuming you also know the user's locale (you can check browser support at http://caniuse.com/#feat=internationalization). If you can use the Intl object, your code becomes something like this...

function parseLocalizedFloat(numstring) {   var intl = new Intl.NumberFormat(userLocale),       dot = /^\d(\D)\d{1,}$/.exec(intl.format(9.99)),       pos = numstring.lastIndexOf(dot);    /* if there is a decimal separator */    if (pos > -1) {      normalized = numstring.substr(0, pos).replace(/\D/g, '') +          '.' +        numstring.substr(pos).replace(/\D/g, '');    } else {      normalized = numstring.replace(/\D/g, '');    }    return parseFloat(normalized); }


Happy coding.

No comments:

Post a Comment