UNPKG

n2words

Version:

Convert numbers to words in 70+ languages with zero dependencies. Supports BigInt, decimals, and browser/Node.js environments.

606 lines (520 loc) 17.7 kB
/** * Dutch (Netherlands) language converter * * CLDR: nl-NL | Dutch as used in the Netherlands * * Dutch-specific rules: * - Inverted tens-ones: eenentwintig (one-and-twenty) * - "ën" connector when ones ends in 'e' (twee, drie) * - Compound words without spaces * - Hundred pairing for 1100-9999 (elfhonderd style) * - "één" vs "een" (accentOne option) * - Optional "en" separator (includeOptionalAnd option) * - Long scale with -ard forms */ import { parseCardinalValue } from './utils/parse-cardinal.js' import { parseCurrencyValue } from './utils/parse-currency.js' import { parseOrdinalValue } from './utils/parse-ordinal.js' import { checkMax } from './utils/check-max.js' import { western } from './utils/scale.js' import { resolveOptions } from './utils/resolve-options.js' // ============================================================================ // Vocabulary (module-level constants) // ============================================================================ const ONES = ['', 'een', 'twee', 'drie', 'vier', 'vijf', 'zes', 'zeven', 'acht', 'negen'] const TEENS = ['tien', 'elf', 'twaalf', 'dertien', 'veertien', 'vijftien', 'zestien', 'zeventien', 'achttien', 'negentien'] const TENS = ['', '', 'twintig', 'dertig', 'veertig', 'vijftig', 'zestig', 'zeventig', 'tachtig', 'negentig'] const HUNDRED = 'honderd' // Scale words (long scale with -ard forms) const SCALES = ['duizend', 'miljoen', 'miljard', 'biljoen', 'biljard', 'triljoen', 'triljard', 'quadriljoen', 'quadriljard'] // Scale ordinal words. Module-scope so its length can derive the ordinal // ceiling and so it isn't rebuilt on every call. const SCALE_ORDINAL = ['', 'duizendste', 'miljoenste', 'miljardste', 'biljoenste', 'biljardste', 'triljoenste'] // Supported magnitude ceilings (checked at the public entry points). SCALES is // indexed [scaleIndex - 1] from 'duizend' (10^3), so cardinals/currency reach // 10^((SCALES.length + 1) * 3) = 10^30. The ordinal of a number whose lowest // non-zero group is a scale group uses the shorter SCALE_ORDINAL, so ordinals // are bounded at 10^(SCALE_ORDINAL.length * 3) = 10^21. export const cardinalMax = western(SCALES.length) export const ordinalMax = western(SCALE_ORDINAL.length - 1) export const currencyMax = western(SCALES.length) const ZERO = 'nul' const NEGATIVE = 'min' const DECIMAL_SEP = 'komma' // Currency vocabulary (Euro) const EURO = 'euro' const EUROS = 'euro' // Euro doesn't pluralize in Dutch const CENT = 'cent' const CENTEN = 'cent' // Cent doesn't pluralize in written currency // ============================================================================ // Segment Building // ============================================================================ /** * Builds segment word for 0-999. * @param {number} n - Segment value * @param {boolean} withAnd - Include "en" for values < 13 after hundreds * @returns {string} Dutch word (compound, no spaces) */ function buildSegment(n, withAnd) { if (n === 0) return '' const ones = n % 10 const tens = Math.trunc(n / 10) % 10 const hundreds = Math.trunc(n / 100) const tensOnes = n % 100 let result = '' // Hundreds if (hundreds > 0) { if (hundreds === 1) { result = HUNDRED } else { result = ONES[hundreds] + HUNDRED } } // Tens and ones if (tensOnes === 0) { // Just hundreds } else if (tensOnes < 10) { // Single digit - add "en" if withAnd and after hundreds if (hundreds > 0 && withAnd) { result += 'en' + ONES[tensOnes] } else { result += ONES[tensOnes] } } else if (tensOnes < 20) { // Teens - add "en" if withAnd and after hundreds and < 13 if (hundreds > 0 && withAnd && tensOnes < 13) { result += 'en' + TEENS[ones] } else { result += TEENS[ones] } } else { // 20-99: Dutch inverts with connector if (ones === 0) { result += TENS[tens] } else { // "ën" if ones ends in 'e' (twee, drie) const onesWord = ONES[ones] const connector = onesWord.endsWith('e') ? 'ën' : 'en' result += onesWord + connector + TENS[tens] } } return result } // ============================================================================ // Conversion Functions // ============================================================================ /** * Converts a non-negative integer to Dutch words. * @param {bigint} n - Non-negative integer to convert * @param {{accentOne: boolean, includeOptionalAnd: boolean, noHundredPairing: boolean}} options - Conversion options * @returns {string} Dutch words */ function integerToWords(n, options) { if (n === 0n) return ZERO const { accentOne, includeOptionalAnd, noHundredPairing } = options // Apply één/een replacement /** * @param {string} word The word to apply accent replacement to * @returns {string} The word with "een" replaced by "één" when accentOne is set */ const applyAccent = (word) => { if (accentOne) { return word.replace(/\been\b/g, 'één') } return word } // Fast path: numbers < 1000 if (n < 1000n) { return applyAccent(buildSegment(Number(n), includeOptionalAnd)) } // Hundred pairing for 1100-9999 if (!noHundredPairing && n >= 1100n && n < 10000n) { const high = Number(n / 100n) const low = Number(n % 100n) // Only use pairing when high is not a multiple of 10 if (high % 10 !== 0) { let result = buildSegment(high, includeOptionalAnd) + HUNDRED if (low > 0) { const lowWord = buildSegment(low, includeOptionalAnd) if (includeOptionalAnd && low < 13) { result += ' en ' + lowWord } else { result += ' ' + lowWord } } return applyAccent(result) } } // Fast path: numbers < 1,000,000 (thousands) if (n < 1_000_000n) { const thousands = Number(n / 1000n) const remainder = Number(n % 1000n) let result if (thousands === 1) { // "duizend" not "eenduizend" result = SCALES[0] } else { // Compound: "vijfduizend" result = buildSegment(thousands, includeOptionalAnd) + SCALES[0] } if (remainder > 0) { const remainderWord = buildSegment(remainder, includeOptionalAnd) if (includeOptionalAnd && remainder < 13) { result += ' en ' + remainderWord } else { result += ' ' + remainderWord } } return applyAccent(result) } // For numbers >= 1,000,000, use scale decomposition return applyAccent(buildLargeNumberWords(n, options)) } /** * Builds words for numbers >= 1,000,000. * Uses BigInt division for faster segment extraction (4x faster than string slicing). * @param {bigint} n - Number >= 1,000,000 * @param {{accentOne: boolean, includeOptionalAnd: boolean, noHundredPairing: boolean}} options - Conversion options * @returns {string} Dutch words */ function buildLargeNumberWords(n, options) { const { includeOptionalAnd } = options // Extract segments using BigInt division (faster than string slicing) // Segments stored least-significant first (index 0 = ones, 1 = thousands, etc.) const segmentValues = [] let temp = n while (temp > 0n) { segmentValues.push(Number(temp % 1000n)) temp = temp / 1000n } // Build result string directly (avoids object allocation and join) let result = '' let prevWasScale = false for (let i = segmentValues.length - 1; i >= 0; i--) { const segment = segmentValues[i] if (segment === 0) continue if (i === 0) { // Units segment const word = buildSegment(segment, includeOptionalAnd) if (result) { if (prevWasScale && includeOptionalAnd && segment < 13) { result += ' en ' + word } else { result += ' ' + word } } else { result = word } prevWasScale = false } else if (i === 1) { // Thousands - compound if (result) result += ' ' if (segment === 1) { result += SCALES[0] } else { result += buildSegment(segment, includeOptionalAnd) + SCALES[0] } prevWasScale = true } else { // Million and above - space around scale const scaleWord = SCALES[i - 1] if (result) result += ' ' if (segment === 1) { result += 'een ' + scaleWord } else { result += buildSegment(segment, includeOptionalAnd) + ' ' + scaleWord } prevWasScale = true } } return result } /** * Converts decimal digits to Dutch words. * @param {string} decimalPart - Decimal digits (without the point) * @param {{accentOne: boolean, includeOptionalAnd: boolean, noHundredPairing: boolean}} options - Conversion options * @returns {string} Dutch words for decimal part */ function decimalPartToWords(decimalPart, options) { let result = '' // Handle leading zeros let i = 0 while (i < decimalPart.length && decimalPart[i] === '0') { if (result) result += ' ' result += ZERO i++ } // Convert remainder as a single number const remainder = decimalPart.slice(i) if (remainder) { if (result) result += ' ' const word = integerToWords(BigInt(remainder), { ...options, noHundredPairing: true }) result += word } return result } /** * @typedef {object} CardinalOptions * @property {boolean} [accentOne] - Use "één" instead of "een" * @property {boolean} [includeOptionalAnd] - Include "en" before small numbers * @property {boolean} [noHundredPairing] - Disable hundred pairing (1104→duizend honderdvier) */ /** @type {Required<CardinalOptions>} */ export const cardinalDefaults = { accentOne: true, includeOptionalAnd: false, noHundredPairing: false } /** * Converts a numeric value to Dutch words. * * This is the main public API. It accepts any valid numeric input * (number, string, or bigint) and handles parsing internally. * @param {number | string | bigint} value - The numeric value to convert * @param {CardinalOptions} [options] - Optional configuration * @returns {string} The number in Dutch words * @throws {TypeError} If value is not a valid numeric type * @throws {Error} If value is not a valid number format * @example * toCardinal(21) // 'eenentwintig' * toCardinal(1) // 'één' * toCardinal(1, {accentOne: false}) // 'een' * toCardinal(1104) // 'elfhonderd vier' */ function toCardinal(value, options) { const { isNegative, integerPart, decimalPart } = parseCardinalValue(value) // Both the integer part and the decimal's significant digits are spelled via // the scale builder, so both must clear the ceiling. checkMax(integerPart, cardinalMax, decimalPart) // Apply option defaults const { accentOne, includeOptionalAnd, noHundredPairing } = resolveOptions(options, cardinalDefaults) const opts = { accentOne, includeOptionalAnd, noHundredPairing } let result = '' if (isNegative) { result = NEGATIVE + ' ' } result += integerToWords(integerPart, opts) if (decimalPart) { result += ' ' + DECIMAL_SEP + ' ' + decimalPartToWords(decimalPart, opts) } return result } // ============================================================================ // Ordinal Functions // ============================================================================ // Ordinal forms for 1-9 const ORDINAL_ONES = ['', 'eerste', 'tweede', 'derde', 'vierde', 'vijfde', 'zesde', 'zevende', 'achtste', 'negende'] /** * Converts a small cardinal to ordinal. * Rules: 1-19 add -de (except eerste, derde, achtste), 20+ add -ste * @param {string} cardinalWord - Cardinal word * @param {number} n - The number value (0-99) * @returns {string} Ordinal word */ function smallCardinalToOrdinal(cardinalWord, n) { // Special cases for 1-9 if (n >= 1 && n <= 9) return ORDINAL_ONES[n] // 10-19: add -de or -e if ends in d if (n < 20) { if (cardinalWord.endsWith('d')) { return cardinalWord + 'e' } return cardinalWord + 'de' } // 20+: add -ste return cardinalWord + 'ste' } /** * Builds ordinal words for 0-999. * @param {number} n - Number 0-999 * @returns {string} Dutch ordinal words */ function buildOrdinalSegment(n) { if (n === 0) return '' const hundreds = Math.trunc(n / 100) const tensOnes = n % 100 // Simple cases: 1-99 if (hundreds === 0) { const cardinalWord = buildSegment(n, false) return smallCardinalToOrdinal(cardinalWord, n) } // Hundreds: need to build prefix + ordinal suffix let prefix if (hundreds === 1) { prefix = HUNDRED } else { prefix = ONES[hundreds] + HUNDRED } if (tensOnes === 0) { // Exact hundred: honderdste, tweehonderdste return prefix + 'ste' } // Hundreds + remainder: honderd eerste, tweehonderd drieëntwintigste // Dutch ordinals with hundreds use a space before the ordinal part const ordinalPart = smallCardinalToOrdinal(buildSegment(tensOnes, false), tensOnes) return prefix + ' ' + ordinalPart } /** * Converts a number to Dutch ordinal words. * @param {number | string | bigint} value - The number to convert * @returns {string} Dutch ordinal words * @example * toOrdinal(1) // 'eerste' * toOrdinal(21) // 'eenentwintigste' * toOrdinal(100) // 'honderdste' */ function toOrdinal(value) { const n = parseOrdinalValue(value) checkMax(n, ordinalMax) // Fast path: 1-9 if (n < 10n) return ORDINAL_ONES[Number(n)] // Fast path: 10-99 if (n < 100n) { const cardinalWord = buildSegment(Number(n), false) return smallCardinalToOrdinal(cardinalWord, Number(n)) } // Fast path: 100-999 if (n < 1000n) { return buildOrdinalSegment(Number(n)) } // Large numbers: build with cardinal then convert last segment return buildLargeOrdinal(n) } /** * Builds ordinal words for large numbers. * @param {bigint} n - Non-negative integer >= 1000 * @returns {string} Dutch ordinal words */ function buildLargeOrdinal(n) { // Extract segments const segments = [] let temp = n while (temp > 0n) { segments.push(Number(temp % 1000n)) temp = temp / 1000n } // Find the lowest non-zero segment let lowestNonZeroIdx = 0 for (let i = 0; i < segments.length; i++) { if (segments[i] !== 0) { lowestNonZeroIdx = i break } } let result = '' for (let i = segments.length - 1; i >= 0; i--) { const segment = segments[i] if (segment === 0) continue if (result) result += ' ' if (i === lowestNonZeroIdx) { // Last non-zero segment gets ordinal form if (i === 0) { // Units segment: use ordinal segment builder result += buildOrdinalSegment(segment) } else if (segment === 1 && i > 0) { // Exact scale: duizendste, miljoenste, etc. if (i === 1) { result += SCALE_ORDINAL[i] } else { result += 'een ' + SCALE_ORDINAL[i] } } else { // Segment + scale ordinal if (i === 1) { result += buildSegment(segment, false) + SCALE_ORDINAL[i] } else { result += buildSegment(segment, false) + ' ' + SCALE_ORDINAL[i] } } } else { // Higher segments use cardinal form if (i === 1) { if (segment === 1) { result += SCALES[0] } else { result += buildSegment(segment, false) + SCALES[0] } } else { const scaleWord = SCALES[i - 1] if (segment === 1) { result += 'een ' + scaleWord } else { result += buildSegment(segment, false) + ' ' + scaleWord } } } } return result } // ============================================================================ // Currency Functions // ============================================================================ /** * @typedef {object} CurrencyOptions * @property {boolean} [and] - Include "en" between euros and cents */ /** @type {Required<CurrencyOptions>} */ export const currencyDefaults = { and: true } /** * Converts a number to Dutch currency words (Euro). * @param {number | string | bigint} value - The amount to convert * @param {CurrencyOptions} [options] Optional configuration * @returns {string} Dutch currency words * @example * toCurrency(42.50) // 'tweeënveertig euro en vijftig cent' * toCurrency(1) // 'één euro' * toCurrency(0.01) // 'één cent' */ function toCurrency(value, options) { const { isNegative, dollars: euros, cents } = parseCurrencyValue(value) checkMax(euros, currencyMax) const { and } = resolveOptions(options, currencyDefaults) let result = '' if (isNegative) { result = NEGATIVE + ' ' } const hasEuros = euros > 0n const hasCents = cents > 0n if (!hasEuros && !hasCents) { return ZERO + ' ' + EUROS } // Use accentOne: true for currency (één euro, één cent) const opts = { accentOne: true, includeOptionalAnd: false, noHundredPairing: true } if (hasEuros) { const euroWords = integerToWords(euros, opts) result += euroWords + ' ' + (euros === 1n ? EURO : EUROS) } if (hasCents) { if (hasEuros) { result += and ? ' en ' : ' ' } const centWords = integerToWords(cents, opts) result += centWords + ' ' + (cents === 1n ? CENT : CENTEN) } return result } // ============================================================================ // Public API // ============================================================================ export { toCardinal, toOrdinal, toCurrency }