UNPKG

n2words

Version:

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

575 lines (488 loc) 17.6 kB
/** * Portuguese (Brazil) language converter * * CLDR: pt-BR | Brazilian Portuguese as used in Brazil * * Portuguese-specific rules: * - "e" conjunction between units, tens and hundreds: vinte e um, cento e um * - "cem" for exact 100, "cento" for 100+ remainder * - Irregular hundreds: duzentos, trezentos, quatrocentos, etc. * - Short scale: milhão (10^6), bilhão (10^9), trilhão (10^12) * - Omit "um" 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 { western } from './utils/scale.js' import { resolveOptions } from './utils/resolve-options.js' // ============================================================================ // Vocabulary (module-level constants) // ============================================================================ const ONES = ['', 'um', 'dois', 'três', 'quatro', 'cinco', 'seis', 'sete', 'oito', 'nove'] const TEENS = ['dez', 'onze', 'doze', 'treze', 'quatorze', 'quinze', 'dezesseis', 'dezessete', 'dezoito', 'dezenove'] const TENS = ['', '', 'vinte', 'trinta', 'quarenta', 'cinquenta', 'sessenta', 'setenta', 'oitenta', 'noventa'] // Irregular hundreds const HUNDREDS = ['', 'cento', 'duzentos', 'trezentos', 'quatrocentos', 'quinhentos', 'seiscentos', 'setecentos', 'oitocentos', 'novecentos'] const THOUSAND = 'mil' const ZERO = 'zero' const NEGATIVE = 'menos' const DECIMAL_SEP = 'vírgula' // Ordinal vocabulary const ORDINAL_ONES = ['', 'primeiro', 'segundo', 'terceiro', 'quarto', 'quinto', 'sexto', 'sétimo', 'oitavo', 'nono'] const ORDINAL_TEENS = ['décimo', 'décimo primeiro', 'décimo segundo', 'décimo terceiro', 'décimo quarto', 'décimo quinto', 'décimo sexto', 'décimo sétimo', 'décimo oitavo', 'décimo nono'] const ORDINAL_TENS = ['', '', 'vigésimo', 'trigésimo', 'quadragésimo', 'quinquagésimo', 'sexagésimo', 'septuagésimo', 'octogésimo', 'nonagésimo'] const ORDINAL_HUNDREDS = ['', 'centésimo', 'ducentésimo', 'tricentésimo', 'quadringentésimo', 'quingentésimo', 'sexcentésimo', 'septingentésimo', 'octingentésimo', 'nongentésimo'] // ============================================================================ // Currency vocabulary // ============================================================================ // Dicionário focado no uso no Brasil (centavos para dólar e euro em vez de cêntimos) /** @type {Record<string, {major: string[], minor: string[]}>} */ const CURRENCIES = { BRL: { major: ['real', 'reais'], minor: ['centavo', 'centavos'] }, USD: { major: ['dólar', 'dólares'], minor: ['centavo', 'centavos'] }, EUR: { major: ['euro', 'euros'], minor: ['centavo', 'centavos'] }, // No Brasil é comum falar "centavos de euro" GBP: { major: ['libra', 'libras'], minor: ['pêni', 'pence'] }, JPY: { major: ['iene', 'ienes'], minor: ['sen', 'sen'] }, // Iene não tem subdivisão usada no dia a dia } // Fallback para caso o usuário passe uma moeda não mapeada (ex: 'CAD') const DEFAULT_CURRENCY_WORDS = { major: ['unidade', 'unidades'], minor: ['centavo', 'centavos'] } // ============================================================================ // Segment Building // ============================================================================ /** * Builds segment word for 0-999 with Portuguese "e" rules. * Returns the word and whether it's an exact hundred (for "cem" handling). * @param {number} n - Number 0-999 * @returns {{word: string, isExactHundred: boolean, startsWithHundreds?: boolean}} The segment word and hundred-related flags */ function buildSegment(n) { if (n === 0) return { word: '', isExactHundred: false } // Special case: exact 100 is "cem" if (n === 100) return { word: 'cem', isExactHundred: true } const ones = n % 10 const tens = Math.trunc(n / 10) % 10 const hundreds = Math.trunc(n / 100) const parts = [] // Hundreds if (hundreds > 0) { parts.push(HUNDREDS[hundreds]) } // Tens and ones if (tens === 1) { // Teens (10-19) parts.push(TEENS[ones]) } else if (tens >= 2) { if (ones > 0) { // Tens + ones with "e": "vinte e um" parts.push(TENS[tens] + ' e ' + ONES[ones]) } else { parts.push(TENS[tens]) } } else if (ones > 0) { parts.push(ONES[ones]) } // Join hundreds with "e": "cento e um", "duzentos e trinta e um" const word = parts.join(' e ') return { word, isExactHundred: hundreds > 0 && tens === 0 && ones === 0, startsWithHundreds: n >= 100 } } // ============================================================================ // Scale Word Lookup (Short Scale for pt-BR) // ============================================================================ // Precompute scale words for singular and plural forms // Index 1 = thousands, 2 = millions, 3 = billions (10^9), etc. const SCALE_WORDS_SINGULAR = [ '', // 0 unused THOUSAND, // 1: mil 'milhão', // 2: 10^6 'bilhão', // 3: 10^9 'trilhão', // 4: 10^12 'quatrilhão', // 5: 10^15 'quintilhão', // 6: 10^18 'sextilhão', // 7: 10^21 'setilhão', // 8: 10^24 ] const SCALE_WORDS_PLURAL = [ '', // 0 unused THOUSAND, // 1: mil (same) 'milhões', // 2: 10^6 'bilhões', // 3: 10^9 'trilhões', // 4: 10^12 'quatrilhões', // 5: 10^15 'quintilhões', // 6: 10^18 'sextilhões', // 7: 10^21 'setilhões', // 8: 10^24 ] // Scale ordinal words (short scale for pt-BR). Module-scope so its length can // derive the ordinal ceiling and so it isn't rebuilt on every call. const SCALE_ORDINAL = ['', 'milésimo', 'milionésimo', 'bilionésimo', 'trilionésimo', 'quatrilionésimo', 'quintilionésimo', 'sextilionésimo'] // Supported magnitude ceilings (checked at the public entry points). Cardinal // scale words reach index SCALE_WORDS_SINGULAR.length-1 (setilhão, 10^24), so // cardinals/currency must stay below 10^27. The ordinal of a number whose // lowest non-zero group is a scale group uses SCALE_ORDINAL, which is shorter, // so ordinals must stay below 10^(SCALE_ORDINAL.length * 3). export const cardinalMax = western(SCALE_WORDS_SINGULAR.length - 1) export const ordinalMax = western(SCALE_ORDINAL.length - 1) export const currencyMax = western(SCALE_WORDS_SINGULAR.length - 1) // ============================================================================ // Conversion Functions // ============================================================================ /** * Converts a non-negative integer to Portuguese words. * @param {bigint} n - Non-negative integer to convert * @returns {string} Portuguese words */ function integerToWords(n) { if (n === 0n) return ZERO // Fast path: numbers < 1000 if (n < 1000n) { return buildSegment(Number(n)).word } // 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 "um mil" result = THOUSAND } else { result = buildSegment(thousands).word + ' ' + THOUSAND } if (remainder > 0) { const remainderResult = buildSegment(remainder) // REGRA DO "E": Menor que 100 OU Centena Exata (ex: 500) if (!remainderResult.startsWithHundreds || remainderResult.isExactHundred) { result += ' e ' + remainderResult.word } else { result += ' ' + remainderResult.word } } return result } // For numbers >= 1,000,000, use scale decomposition return buildLargeNumberWords(n) } /** * Builds words for numbers >= 1,000,000. * Uses BigInt division for faster segment extraction. * @param {bigint} n - Number >= 1,000,000 * @returns {string} Portuguese words */ function buildLargeNumberWords(n) { // Extract segments using BigInt division const segments = [] let temp = n while (temp > 0n) { segments.push(Number(temp % 1000n)) temp = temp / 1000n } // Find the first non-zero segment index let firstNonZeroIdx = 0 for (let i = 0; i < segments.length; i++) { if (segments[i] !== 0) { firstNonZeroIdx = i break } } let result = '' let prevWasScale = false for (let i = segments.length - 1; i >= 0; i--) { const segment = segments[i] if (segment === 0) continue const segmentResult = buildSegment(segment) const isLastSegment = (i === firstNonZeroIdx) // REGRA DO "E": Se for o último segmento e for < 100 OU centena exata (ex: 500) if (result && isLastSegment && prevWasScale && (!segmentResult.startsWithHundreds || segmentResult.isExactHundred)) { result += ' e' } if (result) result += ' ' if (i === 0) { // Units segment result += segmentResult.word prevWasScale = false } else if (i === 1) { // Thousands if (segment === 1) { result += THOUSAND } else { result += segmentResult.word + ' ' + THOUSAND } prevWasScale = true } else { // Million and above const scaleWord = segment === 1 ? SCALE_WORDS_SINGULAR[i] : SCALE_WORDS_PLURAL[i] if (segment === 1) { result += 'um ' + scaleWord } else { result += segmentResult.word + ' ' + scaleWord } prevWasScale = true } } return result } /** * Converts decimal digits to Portuguese words. * @param {string} decimalPart - Decimal digits (without the point) * @returns {string} Portuguese words for decimal part */ function decimalPartToWords(decimalPart) { 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)) } return result } /** * Converts a numeric value to Portuguese words. * @param {number | string | bigint} value - The numeric value to convert * @returns {string} The number in Portuguese words */ function toCardinal(value) { 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) let result = '' if (isNegative) { result = NEGATIVE + ' ' } result += integerToWords(integerPart) if (decimalPart) { result += ' ' + DECIMAL_SEP + ' ' + decimalPartToWords(decimalPart) } return result } // ============================================================================ // Ordinal Functions // ============================================================================ /** * Builds ordinal words for 0-999. * @param {number} n - Number 0-999 * @returns {string} Portuguese ordinal words */ function buildOrdinalSegment(n) { if (n === 0) return '' const ones = n % 10 const tens = Math.trunc(n / 10) % 10 const hundreds = Math.trunc(n / 100) const parts = [] // Hundreds ordinal if (hundreds > 0) { parts.push(ORDINAL_HUNDREDS[hundreds]) } // Tens and ones if (tens === 1) { // 10-19: use teens array (décimo, décimo primeiro, etc.) parts.push(ORDINAL_TEENS[ones]) } else if (tens >= 2) { parts.push(ORDINAL_TENS[tens]) if (ones > 0) { parts.push(ORDINAL_ONES[ones]) } } else if (ones > 0) { parts.push(ORDINAL_ONES[ones]) } return parts.join(' ') } /** * Builds ordinal words for large numbers. * @param {bigint} n - Non-negative integer * @returns {string} Portuguese 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 (index 0 = units, lowest scale) 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: just ordinal result += buildOrdinalSegment(segment) } else if (segment === 1 && i > 0) { // Exact scale: "milésimo", "milionésimo", etc. result += SCALE_ORDINAL[i] } else { // Segment + scale ordinal result += buildOrdinalSegment(segment) + ' ' + SCALE_ORDINAL[i] } } else { // Higher segments use cardinal form if (i === 0) { result += buildSegment(segment).word } else if (i === 1) { if (segment === 1) { result += THOUSAND } else { result += buildSegment(segment).word + ' ' + THOUSAND } } else { const scaleWord = segment === 1 ? SCALE_WORDS_SINGULAR[i] : SCALE_WORDS_PLURAL[i] if (segment === 1) { result += 'um ' + scaleWord } else { result += buildSegment(segment).word + ' ' + scaleWord } } } } return result } /** * Converts a number to Portuguese ordinal words. * @param {number | string | bigint} value - The number to convert * @returns {string} Portuguese ordinal words */ 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-19 if (n < 20n) { return ORDINAL_TEENS[Number(n) - 10] } // Fast path: 20-99 if (n < 100n) { const ones = Number(n % 10n) const tens = Number(n / 10n) if (ones === 0) { return ORDINAL_TENS[tens] } return ORDINAL_TENS[tens] + ' ' + ORDINAL_ONES[ones] } // Fast path: 100-999 if (n < 1000n) { return buildOrdinalSegment(Number(n)) } // Large numbers return buildLargeOrdinal(n) } // ============================================================================ // Currency Functions // ============================================================================ /** * @typedef {object} CurrencyOptions * @property {boolean} [and] - Include "e" between major and minor units * @property {string} [currency] - Currency code (e.g., 'BRL', 'USD'); empty means auto-detect for pt-BR */ /** @type {Required<CurrencyOptions>} */ export const currencyDefaults = { and: true, currency: '' } /** * Converts a number to Brazilian Portuguese currency words. * @param {number | string | bigint} value - The amount to convert * @param {CurrencyOptions} [options] Currency formatting options * @returns {string} Brazilian Portuguese currency words * @example * toCurrency(42.50) // 'quarenta e dois reais e cinquenta centavos' * toCurrency(42.50, {currency: 'USD'}) // 'quarenta e dois dólares e cinquenta centavos' */ function toCurrency(value, options) { const { isNegative, dollars: majorUnits, cents: minorUnits } = parseCurrencyValue(value) checkMax(majorUnits, currencyMax) const { and, currency } = resolveOptions(options, currencyDefaults) // 1. Descobre a moeda informada ou busca automaticamente a padrão do país (pt-BR = BRL) let currencyCode = currency if (!currencyCode) { try { // Intl Locale Info (getCurrencies) is a newer TC39 API present at // runtime in modern engines but not yet in the TS ES2022 lib types; // augment the type locally rather than widen the project's lib. const localeInfo = /** @type {Intl.Locale & { getCurrencies(): string[] }} */ (new Intl.Locale('pt-BR')) currencyCode = localeInfo.getCurrencies?.()[0] } catch { // Ignora erro em ambientes antigos (fallback garantido abaixo) } currencyCode = currencyCode || 'BRL' // Padrão absoluto para o Brasil } currencyCode = currencyCode.toUpperCase() // 2. Busca os nomes no dicionário ou usa o fallback genérico const currencyWords = CURRENCIES[currencyCode] || { major: [currencyCode, currencyCode], minor: DEFAULT_CURRENCY_WORDS.minor, } let result = '' if (isNegative) { result = NEGATIVE + ' ' } const hasMajor = majorUnits > 0n const hasMinor = minorUnits > 0n if (!hasMajor && !hasMinor) { return ZERO + ' ' + currencyWords.major[1] } // Parte inteira (Reais, Dólares...) if (hasMajor) { const majorText = integerToWords(majorUnits) const majorUnit = majorUnits === 1n ? currencyWords.major[0] : currencyWords.major[1] result += majorText + ' ' + majorUnit } // Parte decimal (Centavos...) if (hasMinor) { if (hasMajor) { result += and ? ' e ' : ' ' } const minorText = integerToWords(minorUnits) const minorUnit = minorUnits === 1n ? currencyWords.minor[0] : currencyWords.minor[1] // Ignora adicionar unidade de centavos se a moeda não os tiver (ex: JPY onde minor é string vazia) if (minorUnit === '') { result += minorText } else { result += minorText + ' ' + minorUnit } } return result } // ============================================================================ // Public API // ============================================================================ export { toCardinal, toOrdinal, toCurrency }