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
JavaScript
/**
* 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 }