1
\$\begingroup\$

I did a second version for my original question:


The original question

The reason I ask this question is I was trying to answer a question from a different user: Figure/number to text converter javascript implementation I found it to be too complex so I ended up completely rewriting it. Then it was already answered in a better way. So I thought to see this as an exercise and ask the question myself.

Figure/number to text converter Javascript implementation: my take in Dutch: v1

I read this question Figure/number to text converter javascript implementation. Like the answer said, that version has two main issues. I tried to solve them.

The main issues with the original:

  • The assumption you can switch language by just providing the words for the main numbers is not correct, since numbers are built differently in different languages. My solution: I have one function per language and some global helper functions.
  • It uses too many design patterns, especially fluent APIs. My solution: It consists of function calls with an inner helper function for the hundreds and some outer helper functions.

Please give some feedback on the programming itself. You don't have necessarily have to correct my Dutch ;-)

Note: I only support positive integers. It's a linguistic question, but if someone knows: 2.24 can be pronounced as twee komma vierentwintig (two point twenty four), but how do you pronounce decimal fractions starting with zero like 2.024. Is it twee komma nul vierentwintig (two point zero twenty four) or twee komma nul twee vier (two point zero two four)?


The second version

I took into account the answer of J_H. I used strings to represent the numbers now. It works for huge numbers. I did not pull the transcribe sub-functions (transcribe_xxx,...) to the global scope, because it seems clearer to me they belong in the scope for Dutch transcription. I tried to use an object with the sub-functions as methods for Dutch. I didn't think the approach was easier to read, because it isn't clear those sub-methods are just used inside transcribe. I suppose it's a question of being used to this approach in more object-oriented languages.

A question I did have is the naming of variables: I use a lot of similar variable names. e.g. digit_x00 stands for the digit with weight 100, number_xx stands for a two digit number,... It was easier to write for me then digit_hundreds or number_last_2_digits.

One thing I didn't do is validation. The input is required to be a number in the simplest format: no spaces, commas, trailing zeros,... I don't know if it would be necessary. My idea is: when the function is used, it would probably be used with a parameter that comes from number.toString(), so it would be in the right format. (Now I write this: maybe I could write the global transcribe function to have both types as a parameter and have only the string one be validated.)

const NL = {
    code: 'nl',
    transcribe(number) {
        const NUMBER_0 = 'nul'
        const NUMBER_10 = 'tien'
        const NUMBERS_FROM_0_TO_9 = [NUMBER_0, 'een', 'twee', 'drie', 'vier', 'vijf', 'zes', 'zeven', 'acht', 'negen']
        const NUMBERS_FROM_10_TO_19 = [NUMBER_10, 'elf', 'twaalf', 'dertien', 'veertien', 'vijftien', 'zestien', 'zeventien', 'achttien', 'negentien']
        const MULTIPLES_OF_10 = [undefined, NUMBER_10, 'twintig', 'dertig', 'veertig', 'vijftig', 'zestig', 'zeventig', 'tachtig', 'negentig']
        const NUMBER_100 = 'honderd'
        const NUMBER_1000 = 'duizend'
        const POWERS_OF_1000 = [undefined, NUMBER_1000, 'miljoen', 'miljard', 'biljoen', 'biljard', 'triljoen', 'triljard', 'quadriljoen', 'quadriljard', 'quintiljoen', 'quintiljard']
    
        let [num_sign, str_whole, str_fraction] = split_number_in_components(number)
    
        let text_whole = transcribe_whole_number(str_whole)
        let text_fraction = transcribe_fraction(str_fraction)
    
        let text_numeric = join_defined_strings(text_whole, ' komma ', text_fraction)
    
        let text_full = text_numeric
        if (num_sign === -1) {
            text_full = 'min ' + text_full 
        }
    
        return text_full

        function transcribe_whole_number(str_whole) {
            if (str_whole === '0') {
                return NUMBER_0
            }

            let text_full = []
            let array_str_3_digit_group = [...split_in_digit_groups(str_whole, 3)]
            
            for (let exponent_of_1000 = array_str_3_digit_group.length - 1; exponent_of_1000 >= 0; --exponent_of_1000) {
                let str_xxx = array_str_3_digit_group[exponent_of_1000]
                let num_xxx = num(str_xxx)
                let [digit_x, digit_x0, digit_x00] = split_in_digits(str_xxx, 3)
    
                // transcribe 3 digit group
                let text_xxx = transcribe_3_digit_group(str_xxx)
                
                if (text_xxx === undefined) continue
        
                let text_power_of_1000 = POWERS_OF_1000[exponent_of_1000]
                let text_xxx_power_of_1000
                if (exponent_of_1000 === 0) {
                    text_xxx_power_of_1000 = text_xxx
                } else if (exponent_of_1000 === 1) {
                    if (num_xxx === 1) {
                        text_xxx_power_of_1000 = NUMBER_1000
                    } else {
                        text_xxx_power_of_1000 = text_xxx + text_power_of_1000
                    }
                } else {
                    text_xxx_power_of_1000 = text_xxx + ' ' + text_power_of_1000
                }
                text_full.push(text_xxx_power_of_1000)
            }
        
            return text_full.join(' ')
        }
    
        function transcribe_fraction(str_fraction) {
            if (str_fraction === undefined) {
                return undefined
            }

            let arr_text_fraction = []
            for (let str_digit of str_fraction) {
                let digit = num(str_digit)
                let text_digit = NUMBERS_FROM_0_TO_9[digit]
                arr_text_fraction.push(text_digit)
            }

            return arr_text_fraction.join(' ')
        }


        function transcribe_3_digit_group(str_xxx) {
            let num_xxx = num(str_xxx)
            let [digit_x, digit_x0, digit_x00] = split_in_digits(str_xxx, 3)
    
            // Assemble 00-99 
            let text_xx
            let num_xx = num_xxx % 100
            if (num_xx === 0) {
                text_xx = undefined
            } else if ((num_xx % 10) === 0) { // pure multiple of 10
                text_xx = MULTIPLES_OF_10[digit_x0]
            } else if (num_xx < 10) { // between 1 and 9
                text_xx = NUMBERS_FROM_0_TO_9[digit_x]
            } else if (num_xx < 20) { // between 11 and 19
                text_xx = NUMBERS_FROM_10_TO_19[digit_x]
            } else { // non-pure multiple of 10 between 21 and 99
                let text_x = NUMBERS_FROM_0_TO_9[digit_x]
                let text_x0 = MULTIPLES_OF_10[digit_x0]
                let last_letter_text_digit_x = substring_from_end(text_x, 1)
                let text_and = (/[aeiou]/gi).test(last_letter_text_digit_x) ? 'ën' : 'en'
    
                text_xx = text_x + text_and + text_x0 // e.g. eenentwintig, tweeëntwintig
            }
    
            // Assemble hundreds
            let text_x00
            if (num_xxx < 100) {
                text_x00 = undefined
            } else if (100 <= num_xxx && num_xxx < 200) {
                text_x00 = NUMBER_100 // honderd
            } else {
                text_x00 = NUMBERS_FROM_0_TO_9[digit_x00] + NUMBER_100 // e.g. tweehonderd, driehonderd
            }
    
            // Combine 00-99 and hundreds
            let text_xxx
            if (text_x00 === undefined) {
                text_xxx = text_xx
            } else if (text_xx === undefined) {
                text_xxx = text_x00
            } else if (num_xx <= 12) {
                text_xxx =  text_x00 + 'en' + text_xx
            } else {
                text_xxx =  text_x00 + text_xx
            }
    
            return text_xxx
        }
    }
}

const EN = {
    code: 'en',
    transcribe(number) {
        throw new Error("Not implemented")
    }
}

// Helper functions
/**
 * Returns the sign (1/-1), whole number part (as string) and fraction part (as string) of a number (as string)
 * @param {string} str_number 
 * @returns {[number, string, string]} [sign (=1/-1), whole_number_str, fraction_str]
 */
function split_number_in_components(str_number) {
    let sign_num = str_number[0] === '-' ? -1 : 1
    let numeric_component_str = sign_num === -1 ? str_number.slice(1) : str_number
    let [whole_number_str, fraction_str] = numeric_component_str.split('.')
    return [sign_num, whole_number_str, fraction_str]
}

/**
 * Take a substring of the last length characters
 * @param {string} str 
 * @param {number} length 
 * @returns 
 */
function substring_from_end(str, length) {
    return str.substring(str.length - length)
}

/**
 * Shorthand for Number.parseInt
 * @param {string} str_number
*/
function num(str_number) {
    return Number.parseInt(str_number)
}

/**
 * Split a number (as string) into its digits.
 * The results go from the least to the most significant digit.
 * This makes the indices correspond to the exponents of 10.
 * e.g. split_in_digits("123456", 0) -> [6, 5, 4, 3, 2, 1]
 * @param {string} number_str a positive integer as string
 * @param {number} pad_with_zeros 
 * @returns {number[]}
 */
function split_in_digits(number_str, pad_with_zeros = 0) {
    return Array.from(number_str.padStart(pad_with_zeros, '0')).map(num).reverse()
}

/**
 * Split a number (as string) into groups of digits of a given size
 * The results go from the least to the most significant group.
 * This makes the indices correspond to the exponents of 10**group_size.
 * e.g. split_in_digit_groups("1234567890", 3) -> ["890", "567", "234", "1"]
 * @param {string} number_str a positive integer as string
 * @param {number} group_size the number of digits per group
 */
function * split_in_digit_groups(number_str, group_size) {
    while (true) {
        let split_index = number_str.length - group_size
        let digit_group_str = number_str.substring(split_index)
        number_str = number_str.substring(0, split_index)
        yield digit_group_str
        if (number_str === '') break
    }
}

/**
 * Returns the result of the join of two strings if both are defined, returns the defined string if only one is defined, returns undefined if both are undefined
 * @param {string|undefined} text1 
 * @param {string|undefined} text2 
 * @param {string|((t1: string, t2: string) => string|undefined)} join
 * @returns {string|undefined}
 */
function join_defined_strings(text1, join, text2) {
    if (text1 === undefined && text2 === undefined) {
        return undefined
    } if (text1 === undefined) {
        return text2
    } else if (text2 === undefined) {
        return text1
    } else {
        return text1 + join + text2
    }
}

let Languages = (function () {
    let languages = {}
    for (let language of [NL, EN]) {
        languages[language.code] = language
    }
    return languages
})();

/**
 * Transcribes a number into text in a given language
 * @param {string} string_number 
 * @param {string} language 
 */
function transcribe(string_number, language) {
    if (Languages[language]) {
        return Languages[language].transcribe(string_number);
    } else {
        throw new Error("Language not supported" + language);
    }
}
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

Short review;

  • First off, would maintain this, it is indeed far more maintainable/readable

  • I would replace let Languages = (function () { with a

    const languages = {
       nl: ...,
       en: ...
    }
    

    You can drop the code: 'nl' parts

  • Love that the code properly transcribes 33 to drie­ën­dertig, however checking for /[aeiou]/ is overkill, we know it can only be 'e'

  • JS is really lowerCamelCase, so text_whole -> wholeText

  • I dont know why you have NUMBER_10 as "tien", the value will never change and it's longer to type, same for "nul"

  • I would set num_sign immediately to either "-" or "", makes things easier to code/write

  • join_defined_strings(text1, join, text2) misleads about what it does, I would go for

      function concat_number(whole, comma, fraction) {
          whole = whole || '';
          fraction = fraction || ''
          comma = (whole && fraction) ? comma : '';
          return whole + komma + faction
      }
    
\$\endgroup\$
1
  • \$\begingroup\$ OK, thank you for your short review, mijnheer konijn. I can understand your remarks, but I'm happy there are no large issues left. \$\endgroup\$ Commented Nov 26 at 12:23

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.