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