Source

INumber.js

/*
 * INumber.js - Parse a number in any locale
 *
 * Copyright © 2012-2015, JEDLSoft
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

var ilib = require("../index.js");

var Locale = require("./Locale.js");
var LocaleInfo = require("./LocaleInfo.js");

var isDigit = require("./isDigit.js");
var isSpace = require("./isSpace.js");

var Currency = require("./Currency.js");


/**
 * @class
 * Parse a string as a number, ignoring all locale-specific formatting.<p>
 *
 * This class is different from the standard Javascript parseInt() and parseFloat()
 * functions in that the number to be parsed can have formatting characters in it
 * that are not supported by those two
 * functions, and it handles numbers written in other locales properly. For example,
 * if you pass the string "203,231.23" to the parseFloat() function in Javascript, it
 * will return you the number 203. The INumber class will parse it correctly and
 * the value() function will return the number 203231.23. If you pass parseFloat() the
 * string "203.231,23" with the locale set to de-DE, it will return you 203 again. This
 * class will return the correct number 203231.23 again.<p>
 *
 * The options object may contain any of the following properties:
 *
 * <ul>
 * <li><i>locale</i> - specify the locale of the string to parse. This is used to
 * figure out what the decimal point character is. If not specified, the default locale
 * for the app or browser is used.
 * <li><i>type</i> - specify whether this string should be interpretted as a number,
 * currency, or percentage amount. When the number is interpretted as a currency
 * amount, the getCurrency() method will return something useful, otherwise it will
 * return undefined. If
 * the number is to be interpretted as percentage amount and there is a percentage sign
 * in the string, then the number will be returned
 * as a fraction from the valueOf() method. If there is no percentage sign, then the
 * number will be returned as a regular number. That is "58.3%" will be returned as the
 * number 0.583 but "58.3" will be returned as 58.3. Valid values for this property
 * are "number", "currency", and "percentage". Default if this is not specified is
 * "number".
 * <li><i>onLoad</i> - a callback function to call when the locale data is fully
 * loaded. When the onLoad option is given, this class will attempt to
 * load any missing locale data using the ilib loader callback.
 * When the constructor is done (even if the data is already preassembled), the
 * onLoad function is called with the current instance as a parameter, so this
 * callback can be used with preassembled or dynamic loading or a mix of the two.
 *
 * <li><i>sync</i> - tell whether to load any missing locale data synchronously or
 * asynchronously. If this option is given as "false", then the "onLoad"
 * callback must be given, as the instance returned from this constructor will
 * not be usable for a while.
 *
 * <li><i>loadParams</i> - an object containing parameters to pass to the
 * loader callback function when locale data is missing. The parameters are not
 * interpretted or modified in any way. They are simply passed along. The object
 * may contain any property/value pairs as long as the calling code is in
 * agreement with the loader callback function as to what those parameters mean.
 * </ul>
 * <p>
 *
 * This class is named INumber ("ilib number") so as not to conflict with the
 * built-in Javascript Number class.
 *
 * @constructor
 * @param {string|number|INumber|Number|undefined} str a string to parse as a number, or a number value
 * @param {Object=} options Options controlling how the instance should be created
 */
var INumber = function (str, options) {
    var i, stripped = "",
        sync = true;

    this.locale = new Locale();
    this.type = "number";

    if (options) {
        if (options.locale) {
            this.locale = (typeof(options.locale) === 'string') ? new Locale(options.locale) : options.locale;
        }
        if (options.type) {
            switch (options.type) {
                case "number":
                case "currency":
                case "percentage":
                    this.type = options.type;
                    break;
                default:
                    break;
            }
        }
        if (typeof(options.sync) !== 'undefined') {
            sync = !!options.sync;
        }
    } else {
        options = {sync: true};
    }

    isDigit._init(sync, options.loadParams, ilib.bind(this, function() {
        isSpace._init(sync, options.loadParams, ilib.bind(this, function() {
            new LocaleInfo(this.locale, {
                sync: sync,
                loadParams: options.loadParams,
                onLoad: ilib.bind(this, function (li) {
                    this.li = li;
                    this.decimal = li.getDecimalSeparator();
                    var nativeDecimal = this.li.getNativeDecimalSeparator() || "";

                    switch (typeof(str)) {
                    case 'string':
                        // stripping should work for all locales, because you just ignore all the
                        // formatting except the decimal char
                        var unary = true; // looking for the unary minus still?
                        var lastNumericChar = 0;
                        this.str = str || "0";
                        i = 0;
                        for (i = 0; i < this.str.length; i++) {
                            if (unary && this.str.charAt(i) === '-') {
                                unary = false;
                                stripped += this.str.charAt(i);
                                lastNumericChar = i;
                            } else if (isDigit(this.str.charAt(i))) {
                                stripped += this.str.charAt(i);
                                unary = false;
                                lastNumericChar = i;
                            } else if (this.str.charAt(i) === this.decimal || this.str.charAt(i) === nativeDecimal) {
                                stripped += "."; // always convert to period
                                unary = false;
                                lastNumericChar = i;
                            } // else ignore
                        }
                        // record what we actually parsed
                        this.parsed = this.str.substring(0, lastNumericChar+1);

                        /** @type {number} */
                        this.value = parseFloat(this._mapToLatinDigits(stripped));
                        break;
                    case 'number':
                        this.str = "" + str;
                        this.value = str;
                        break;

                    case 'object':
                        // call parseFloat to coerse the type to number
                        this.value = parseFloat(str.valueOf());
                        this.str = "" + this.value;
                        break;

                    case 'undefined':
                        this.value = 0;
                        this.str = "0";
                        break;
                    }

                    switch (this.type) {
                        default:
                            // don't need to do anything special for other types
                            break;
                        case "percentage":
                            if (this.str.indexOf(li.getPercentageSymbol()) !== -1) {
                                this.value /= 100;
                            }
                            break;
                        case "currency":
                            stripped = "";
                            i = 0;
                            while (i < this.str.length &&
                                   !isDigit(this.str.charAt(i)) &&
                                   !isSpace(this.str.charAt(i))) {
                                stripped += this.str.charAt(i++);
                            }
                            if (stripped.length === 0) {
                                while (i < this.str.length &&
                                       isDigit(this.str.charAt(i)) ||
                                       isSpace(this.str.charAt(i)) ||
                                       this.str.charAt(i) === '.' ||
                                       this.str.charAt(i) === ',' ) {
                                    i++;
                                }
                                while (i < this.str.length &&
                                       !isDigit(this.str.charAt(i)) &&
                                       !isSpace(this.str.charAt(i))) {
                                    stripped += this.str.charAt(i++);
                                }
                            }
                            new Currency({
                                locale: this.locale,
                                sign: stripped,
                                sync: sync,
                                loadParams: options.loadParams,
                                onLoad: ilib.bind(this, function (cur) {
                                    this.currency = cur;
                                    if (options && typeof(options.onLoad) === 'function') {
                                        options.onLoad(this);
                                    }
                                })
                            });
                            return;
                    }

                    if (options && typeof(options.onLoad) === 'function') {
                        options.onLoad(this);
                    }
                })
            });
        }));
    }));
};

INumber.prototype = {
    /**
     * @private
     */
    _mapToLatinDigits: function(str) {
        // only map if there are actual native digits
        var digits = this.li.getNativeDigits();
        if (!digits) return str;

        var digitMap = {};
        for (var i = 0; i < digits.length; i++) {
            digitMap[digits[i]] = String(i);
        }
        var decimal = this.li.getNativeDecimalSeparator();

        return str.split("").map(function(ch) {
            if (ch == decimal) return ".";
            return digitMap[ch] || ch;
        }).join("");
    },

    /**
     * Return the locale for this formatter instance.
     * @return {Locale} the locale instance for this formatter
     */
    getLocale: function () {
        return this.locale;
    },

    /**
     * Return the original string that this number instance was created with.
     * @return {string} the original string
     */
    toString: function () {
        return this.str;
    },

    /**
     * If the type of this INumber instance is "currency", then the parser will attempt
     * to figure out which currency this amount represents. The amount can be written
     * with any of the currency signs or ISO 4217 codes that are currently
     * recognized by ilib, and the currency signs may occur before or after the
     * numeric portion of the string. If no currency can be recognized, then the
     * default currency for the locale is returned. If multiple currencies can be
     * recognized (for example if the currency sign is "$"), then this method
     * will prefer the one for the current locale. If multiple currencies can be
     * recognized, but none are used in the current locale, then the first currency
     * encountered will be used. This may produce random results, though the larger
     * currencies occur earlier in the list. For example, if the sign found in the
     * string is "$" and that is not the sign of the currency of the current locale
     * then the US dollar will be recognized, as it is the largest currency that uses
     * the "$" as its sign.
     *
     * @return {Currency|undefined} the currency instance for this amount, or
     * undefined if this INumber object is not of type currency
     */
    getCurrency: function () {
        return this.currency;
    },

    /**
     * Return the value of this INumber object as a primitive number instance.
     * @return {number} the value of this number instance
     */
    valueOf: function () {
        return this.value;
    }
};

module.exports = INumber;