UNPKG

n2words

Version:

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

574 lines (483 loc) 18.5 kB
/** * Spanish (Mexico) language converter * * CLDR: es-MX | Spanish as used in Mexico * * Uses the European long scale numbering system (per RAE and Academia Mexicana): * - 10⁶ = millón * - 10⁹ = mil millones (thousand millions) * - 10¹² = billón * * 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, longScale } 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 (compound long scale) const SCALES = ['millón', 'billón', 'trillón', 'cuatrillón'] const SCALES_PLURAL = ['millones', 'billones', 'trillones', 'cuatrillones'] // Supported magnitude ceilings (checked at the public entry points). Each // SCALES entry spans two segment groups (X and "mil X"); with the units group // that's 2 * SCALES.length + 2 groups of 3 digits, so cardinals must stay below // 10^30. Ordinals are bounded lower: the millions multiplier uses // buildOrdinalSegment (0-999), so n must stay below 10^9. export const cardinalMax = longScale(SCALES.length) export const ordinalMax = bounded(9) export const currencyMax = longScale(SCALES.length) const THOUSAND = 'mil' const ZERO = 'cero' const NEGATIVE = 'menos' const DECIMAL_SEP = 'punto' // Ordinal vocabulary (identical to es-ES - ordinals don't vary by region) 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 (Mexican Peso - MXN) const PESO = 'peso' const PESOS = 'pesos' 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(' ') } // ============================================================================ // Helper Functions // ============================================================================ /** * Gets scale word for Spanish compound long scale. * @param {number} scaleIndex - Scale level (1 = thousand, 2 = million, etc.) * @param {bigint} segment - Segment value for pluralization * @returns {string} Scale word */ function getScaleWord(scaleIndex, segment) { if (scaleIndex === 1) return THOUSAND // Even indices (2, 4, 6, 8): millón, billón, trillón, cuatrillón // Odd indices > 1 (3, 5, 7): mil millones, mil billones, mil trillones if (scaleIndex % 2 === 0) { const arrayIndex = (scaleIndex / 2) - 1 const baseWord = SCALES[arrayIndex] if (!baseWord) return '' return segment > 1n ? SCALES_PLURAL[arrayIndex] : baseWord } else { // Compound: "mil millones" pattern const arrayIndex = ((scaleIndex - 1) / 2) - 1 const pluralWord = SCALES_PLURAL[arrayIndex] if (!pluralWord) return THOUSAND return THOUSAND + ' ' + pluralWord } } // ============================================================================ // Conversion Functions // ============================================================================ /** * Converts a non-negative integer to Spanish words. * @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) } // 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) { // "mil" not "uno mil" result = THOUSAND } else { // Use masculine for thousands segment, but check for "uno" → omit before mil const thousandsWord = buildSegment(thousands, false) // "uno mil" → "mil" (handled in joinSegments equivalent) if (thousandsWord === 'uno' || thousandsWord === 'una') { result = THOUSAND } else { result = thousandsWord + ' ' + THOUSAND } } if (remainder > 0) { result += ' ' + buildSegment(remainder, feminine) } return result } // For numbers >= 1,000,000, use scale decomposition return buildLargeNumberWords(n, feminine) } /** * Builds words for numbers >= 1,000,000. * Uses BigInt division for faster segment extraction. * @param {bigint} n - Number >= 1,000,000 * @param {boolean} feminine - Use feminine forms * @returns {string} Spanish words */ function buildLargeNumberWords(n, feminine) { // Extract segments using BigInt division (faster than string slicing) // Segments stored least-significant first (index 0 = ones, 1 = thousands, etc.) // Callers guard the magnitude (cardinalMax) before reaching here. const segmentValues = [] let temp = n while (temp > 0n) { segmentValues.push(temp % 1000n) temp = temp / 1000n } // Build result string directly let result = '' for (let i = segmentValues.length - 1; i >= 0; i--) { const segment = segmentValues[i] if (segment === 0n) continue const scaleWord = i > 0 ? getScaleWord(i, segment) : '' if (result) result += ' ' if (i === 0) { // Units segment result += buildSegment(Number(segment), feminine) } else if (i === 1) { // Thousands: omit "uno" before mil if (segment === 1n) { result += THOUSAND } else { result += buildSegment(Number(segment), false) + ' ' + scaleWord } } else if (i % 2 === 1) { // Odd scale indices (3, 5, 7): "mil millones", "mil billones", etc. // Omit "uno" before these compound scales if (segment === 1n) { result += scaleWord } else { result += buildSegment(Number(segment), false) + ' ' + scaleWord } } else { // Even scale indices (2, 4, 6): millón, billón, trillón if (segment === 1n) { // "un millón" not "uno millón" result += 'un ' + scaleWord } else { // Use masculine for scale segment result += buildSegment(Number(segment), false) + ' ' + scaleWord } } } 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 (long 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) // 'mil millones' */ 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 pesos and centavos */ /** @type {Required<CurrencyOptions>} */ export const currencyDefaults = { and: true } /** * Converts a numeric value to Mexican Peso currency words. * * Mexican currency uses masculine gender for pesos (el peso) * 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 Mexican 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 pesos con cincuenta centavos' * toCurrency(1) // 'un peso' * toCurrency(0.99) // 'noventa y nueve centavos' * toCurrency(42.50, { and: false }) // 'cuarenta y dos pesos cincuenta centavos' */ function toCurrency(value, options) { const { isNegative, dollars: pesos, cents: centavos } = parseCurrencyValue(value) checkMax(pesos, currencyMax) const { and: useAnd } = resolveOptions(options, currencyDefaults) let result = '' if (isNegative) result = NEGATIVE + ' ' // Pesos part (show if non-zero, or if no centavos) if (pesos > 0n || centavos === 0n) { // Use masculine for pesos, but "un peso" not "uno peso" if (pesos === 1n) { result += 'un ' + PESO } else { result += integerToWords(pesos, false) + ' ' + PESOS } } // Centavos part if (centavos > 0n) { if (pesos > 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 }