UNPKG

n2words

Version:

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

488 lines (407 loc) 15.9 kB
/** * Spanish (United States) language converter * * CLDR: es-US | Spanish as used in the United States * * Uses the short scale numbering system (like US English): * - 10⁶ = millón * - 10⁹ = billón (billion) * - 10¹² = trillón (trillion) * * Spanish-specific rules: * - Gender agreement: uno/una, veintiuno/veintiuna, hundreds * - Special twenties: veinte, veintiuno, veintidós, ... veintinueve * - "y" conjunction: treinta y uno (only 30-99 with ones) * - "cien" for exact 100, "ciento/cienta" otherwise * - Irregular hundreds: quinientos, setecientos, novecientos * - "un" before millón (not "uno"), omit before mil */ 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 { bounded, western } from './utils/scale.js' import { resolveOptions } from './utils/resolve-options.js' // ============================================================================ // Vocabulary (module-level constants) // ============================================================================ const ONES_MASC = ['', 'uno', 'dos', 'tres', 'cuatro', 'cinco', 'seis', 'siete', 'ocho', 'nueve'] const ONES_FEM = ['', 'una', 'dos', 'tres', 'cuatro', 'cinco', 'seis', 'siete', 'ocho', 'nueve'] const TEENS = ['diez', 'once', 'doce', 'trece', 'catorce', 'quince', 'dieciseis', 'diecisiete', 'dieciocho', 'diecinueve'] // 20-29 have special compound forms const TWENTIES_MASC = ['veinte', 'veintiuno', 'veintidós', 'veintitrés', 'veinticuatro', 'veinticinco', 'veintiséis', 'veintisiete', 'veintiocho', 'veintinueve'] const TWENTIES_FEM = ['veinte', 'veintiuna', 'veintidós', 'veintitrés', 'veinticuatro', 'veinticinco', 'veintiséis', 'veintisiete', 'veintiocho', 'veintinueve'] const TENS = ['', '', '', 'treinta', 'cuarenta', 'cincuenta', 'sesenta', 'setenta', 'ochenta', 'noventa'] // Irregular hundreds const HUNDREDS_MASC = ['', 'ciento', 'doscientos', 'trescientos', 'cuatrocientos', 'quinientos', 'seiscientos', 'setecientos', 'ochocientos', 'novecientos'] const HUNDREDS_FEM = ['', 'cienta', 'doscientas', 'trescientas', 'cuatrocientas', 'quinientas', 'seiscientas', 'setecientas', 'ochocientas', 'novecientas'] // Scale words (short scale - each scale is 10^3 apart) const SCALES = ['mil', 'millón', 'billón', 'trillón', 'cuatrillón', 'quintillón'] const SCALES_PLURAL = ['mil', 'millones', 'billones', 'trillones', 'cuatrillones', 'quintillones'] // Supported magnitude ceilings (checked at the public entry points). Short // scale: SCALES covers 10^3..10^18, plus the units group, so cardinals span at // most SCALES.length + 1 groups of 3 digits (below 10^21). Ordinals are bounded // lower: the millions multiplier uses buildOrdinalSegment (0-999), so n < 10^9. export const cardinalMax = western(SCALES.length) export const ordinalMax = bounded(9) export const currencyMax = western(SCALES.length) const ZERO = 'cero' const NEGATIVE = 'menos' const DECIMAL_SEP = 'punto' // Ordinal vocabulary (identical across Spanish regions) const ORDINAL_ONES_MASC = ['', 'primero', 'segundo', 'tercero', 'cuarto', 'quinto', 'sexto', 'séptimo', 'octavo', 'noveno'] const ORDINAL_ONES_FEM = ['', 'primera', 'segunda', 'tercera', 'cuarta', 'quinta', 'sexta', 'séptima', 'octava', 'novena'] const ORDINAL_TENS_MASC = ['', 'décimo', 'vigésimo', 'trigésimo', 'cuadragésimo', 'quincuagésimo', 'sexagésimo', 'septuagésimo', 'octogésimo', 'nonagésimo'] const ORDINAL_TENS_FEM = ['', 'décima', 'vigésima', 'trigésima', 'cuadragésima', 'quincuagésima', 'sexagésima', 'septuagésima', 'octogésima', 'nonagésima'] const ORDINAL_HUNDRED_MASC = 'centésimo' const ORDINAL_HUNDRED_FEM = 'centésima' const ORDINAL_THOUSAND_MASC = 'milésimo' const ORDINAL_THOUSAND_FEM = 'milésima' const ORDINAL_MILLION_MASC = 'millonésimo' const ORDINAL_MILLION_FEM = 'millonésima' // Currency vocabulary (US Dollar - USD) const DOLAR = 'dólar' const DOLARES = 'dólares' const CENTAVO = 'centavo' const CENTAVOS = 'centavos' const CURRENCY_CONNECTOR = 'con' // ============================================================================ // Segment Building // ============================================================================ /** * Builds segment word for 0-999. * @param {number} n - Segment value * @param {boolean} feminine - Use feminine forms * @returns {string} Spanish word */ function buildSegment(n, feminine) { if (n === 0) return '' // Special case: exact 100 is "cien" (no gender) if (n === 100) return 'cien' const ones = n % 10 const tens = Math.trunc(n / 10) % 10 const hundreds = Math.trunc(n / 100) const tensOnes = n % 100 const parts = [] // Hundreds if (hundreds > 0) { const hundredsArr = feminine ? HUNDREDS_FEM : HUNDREDS_MASC parts.push(hundredsArr[hundreds]) } // Tens and ones if (tensOnes === 0) { // Just hundreds } else if (tensOnes < 10) { // Single digit const onesArr = feminine ? ONES_FEM : ONES_MASC parts.push(onesArr[tensOnes]) } else if (tensOnes < 20) { // 10-19: teens parts.push(TEENS[ones]) } else if (tensOnes < 30) { // 20-29: special twenties const twentiesArr = feminine ? TWENTIES_FEM : TWENTIES_MASC parts.push(twentiesArr[ones]) } else { // 30-99: tens y ones if (ones === 0) { parts.push(TENS[tens]) } else { const onesArr = feminine ? ONES_FEM : ONES_MASC parts.push(TENS[tens] + ' y ' + onesArr[ones]) } } return parts.join(' ') } // ============================================================================ // Conversion Functions // ============================================================================ /** * Converts a non-negative integer to Spanish words (short scale). * @param {bigint} n - Non-negative integer to convert * @param {boolean} feminine - Use feminine forms * @returns {string} Spanish words */ function integerToWords(n, feminine) { if (n === 0n) return ZERO // Fast path: numbers < 1000 if (n < 1000n) { return buildSegment(Number(n), feminine) } // Extract segments using BigInt division // Each segment is 3 digits, short scale increments by 10^3 const segmentValues = [] let temp = n while (temp > 0n) { segmentValues.push(temp % 1000n) temp = temp / 1000n } // Build result string let result = '' for (let i = segmentValues.length - 1; i >= 0; i--) { const segment = segmentValues[i] if (segment === 0n) continue if (result) result += ' ' if (i === 0) { // Units segment - use requested gender result += buildSegment(Number(segment), feminine) } else if (i === 1) { // Thousands: "mil" not "uno mil" if (segment === 1n) { result += SCALES[0] } else { result += buildSegment(Number(segment), false) + ' ' + SCALES[0] } } else { // Millions and above: "un millón", "dos millones", etc. // Callers guard the magnitude (cardinalMax) so scaleIndex stays in range. const scaleIndex = i - 1 // SCALES[1] = millón, SCALES[2] = billón, etc. if (segment === 1n) { // "un millón" not "uno millón" result += 'un ' + SCALES[scaleIndex] } else { result += buildSegment(Number(segment), false) + ' ' + SCALES_PLURAL[scaleIndex] } } } return result } /** * Converts decimal digits to Spanish words. * @param {string} decimalPart - Decimal digits (without the point) * @param {boolean} feminine - Use feminine forms * @returns {string} Spanish words for decimal part */ function decimalPartToWords(decimalPart, feminine) { 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 += ' ' result += integerToWords(BigInt(remainder), feminine) } return result } /** * @typedef {object} CardinalOptions * @property {('masculine'|'feminine')} [gender] - Grammatical gender */ /** @type {Required<CardinalOptions>} */ export const cardinalDefaults = { gender: 'masculine' } /** @type {{ gender: ReadonlyArray<Required<CardinalOptions>['gender']> }} */ export const cardinalValues = { gender: ['masculine', 'feminine'] } /** * Converts a numeric value to Spanish words (US short scale). * @param {number | string | bigint} value - The numeric value to convert * @param {CardinalOptions} [options] - Optional configuration * @returns {string} The number in Spanish words * @throws {TypeError} If value is not a valid numeric type * @throws {Error} If value is not a valid number format * @example * toCardinal(21) // 'veintiuno' * toCardinal(21, {gender: 'feminine'}) // 'veintiuna' * toCardinal(1000000000) // 'un billón' */ 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 { gender } = resolveOptions(options, cardinalDefaults, cardinalValues) const feminine = gender === 'feminine' let result = '' if (isNegative) { result = NEGATIVE + ' ' } result += integerToWords(integerPart, feminine) if (decimalPart) { result += ' ' + DECIMAL_SEP + ' ' + decimalPartToWords(decimalPart, feminine) } return result } // ============================================================================ // ORDINAL: toOrdinal(value, options?) // ============================================================================ /** * Builds ordinal word for a 0-999 segment. * @param {number} n - Segment value 0-999 * @param {boolean} feminine - Use feminine forms * @returns {string} Spanish ordinal word */ function buildOrdinalSegment(n, feminine) { if (n === 0) return '' const ones = n % 10 const tens = Math.trunc(n / 10) % 10 const hundreds = Math.trunc(n / 100) const onesArr = feminine ? ORDINAL_ONES_FEM : ORDINAL_ONES_MASC const tensArr = feminine ? ORDINAL_TENS_FEM : ORDINAL_TENS_MASC const hundredWord = feminine ? ORDINAL_HUNDRED_FEM : ORDINAL_HUNDRED_MASC const parts = [] // Hundreds if (hundreds > 0) { if (hundreds === 1) { parts.push(hundredWord) } else { const prefixes = ['', '', 'du', 'tri', 'cuadri', 'quin', 'sex', 'septi', 'octi', 'noni'] parts.push(prefixes[hundreds] + hundredWord) } } // Tens if (tens > 0) { parts.push(tensArr[tens]) } // Ones if (ones > 0) { parts.push(onesArr[ones]) } return parts.join(' ') } /** * Converts a positive integer to Spanish ordinal words. * @param {bigint} n - Positive integer to convert * @param {boolean} feminine - Use feminine forms * @returns {string} Spanish ordinal words */ function integerToOrdinal(n, feminine) { const thousandWord = feminine ? ORDINAL_THOUSAND_FEM : ORDINAL_THOUSAND_MASC const millionWord = feminine ? ORDINAL_MILLION_FEM : ORDINAL_MILLION_MASC // Fast path: numbers < 1000 if (n < 1000n) { return buildOrdinalSegment(Number(n), feminine) } // Numbers 1000-999999 if (n < 1_000_000n) { const thousands = Number(n / 1000n) const remainder = Number(n % 1000n) let result if (thousands === 1) { result = thousandWord } else { result = buildOrdinalSegment(thousands, feminine) + ' ' + thousandWord } if (remainder > 0) { result += ' ' + buildOrdinalSegment(remainder, feminine) } return result } // Numbers >= 1,000,000 const millions = Number(n / 1_000_000n) const remainder = n % 1_000_000n let result if (millions === 1) { result = millionWord } else { result = buildOrdinalSegment(millions, feminine) + ' ' + millionWord } if (remainder > 0n) { result += ' ' + integerToOrdinal(remainder, feminine) } return result } /** * @typedef {object} OrdinalOptions * @property {('masculine'|'feminine')} [gender] - Grammatical gender */ /** @type {Required<OrdinalOptions>} */ export const ordinalDefaults = { gender: 'masculine' } /** @type {{ gender: ReadonlyArray<Required<OrdinalOptions>['gender']> }} */ export const ordinalValues = { gender: ['masculine', 'feminine'] } /** * Converts a numeric value to Spanish ordinal words. * @param {number | string | bigint} value - The positive integer to convert * @param {OrdinalOptions} [options] - Optional configuration * @returns {string} The number in Spanish ordinal words * @throws {TypeError} If value is not a valid numeric type * @throws {Error} If value is not a positive integer * @example * toOrdinal(1) // 'primero' * toOrdinal(1, { gender: 'feminine' }) // 'primera' * toOrdinal(21) // 'vigésimo primero' */ function toOrdinal(value, options) { const integerPart = parseOrdinalValue(value) checkMax(integerPart, ordinalMax) const { gender } = resolveOptions(options, ordinalDefaults, ordinalValues) const feminine = gender === 'feminine' return integerToOrdinal(integerPart, feminine) } // ============================================================================ // CURRENCY: toCurrency(value, options?) // ============================================================================ /** * @typedef {object} CurrencyOptions * @property {boolean} [and] - Use "con" between dollars and cents */ /** @type {Required<CurrencyOptions>} */ export const currencyDefaults = { and: true } /** * Converts a numeric value to US Dollar currency words in Spanish. * * US Dollar uses masculine gender for dólares (el dólar) * and masculine for centavos (el centavo). * @param {number | string | bigint} value - The currency amount to convert * @param {CurrencyOptions} [options] - Optional configuration * @returns {string} The amount in Spanish US Dollar currency words * @throws {TypeError} If value is not a valid numeric type * @throws {Error} If value is not a valid number format * @example * toCurrency(42.50) // 'cuarenta y dos dólares con cincuenta centavos' * toCurrency(1) // 'un dólar' * toCurrency(0.99) // 'noventa y nueve centavos' * toCurrency(42.50, { and: false }) // 'cuarenta y dos dólares cincuenta centavos' */ function toCurrency(value, options) { const { isNegative, dollars, cents: centavos } = parseCurrencyValue(value) checkMax(dollars, currencyMax) const { and: useAnd } = resolveOptions(options, currencyDefaults) let result = '' if (isNegative) result = NEGATIVE + ' ' // Dollars part (show if non-zero, or if no centavos) if (dollars > 0n || centavos === 0n) { // Use masculine for dollars, but "un dólar" not "uno dólar" if (dollars === 1n) { result += 'un ' + DOLAR } else { result += integerToWords(dollars, false) + ' ' + DOLARES } } // Centavos part if (centavos > 0n) { if (dollars > 0n) { result += useAnd ? ' ' + CURRENCY_CONNECTOR + ' ' : ' ' } // Use masculine for centavos, but "un centavo" not "uno centavo" if (centavos === 1n) { result += 'un ' + CENTAVO } else { result += integerToWords(centavos, false) + ' ' + CENTAVOS } } return result } // ============================================================================ // Public API // ============================================================================ export { toCardinal, toOrdinal, toCurrency }