n2words
Version:
Convert numbers to words in 70+ languages with zero dependencies. Supports BigInt, decimals, and browser/Node.js environments.
547 lines (465 loc) • 15.9 kB
JavaScript
/**
* Portuguese (Portugal) language converter
*
* CLDR: pt-PT | European Portuguese as used in Portugal
*
* Portuguese-specific rules:
* - "e" conjunction everywhere: vinte e um, cento e um, mil e um
* - "cem" for exact 100, "cento" for 100+ remainder
* - Irregular hundreds: duzentos, trezentos, quatrocentos, etc.
* - Long scale: milhão (10^6), mil milhões (10^9), biliã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', 'catorze', 'quinze', 'dezasseis', 'dezassete', 'dezoito', 'dezanove']
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 (Euro)
const EURO = 'euro'
const EUROS = 'euros'
const CENTIMO = 'cêntimo'
const CENTIMOS = 'cêntimos'
// ============================================================================
// 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 "e"/"cem" handling 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
// ============================================================================
// Precompute scale words for singular and plural forms
// Index 1 = thousands, 2 = millions, 3 = billions (mil milhões), etc.
const SCALE_WORDS_SINGULAR = [
'', // 0 unused
THOUSAND, // 1: mil
'milhão', // 2: 10^6
'mil milhões', // 3: 10^9 (compound)
'bilião', // 4: 10^12
'mil biliões', // 5: 10^15 (compound)
'trilião', // 6: 10^18
'mil triliões', // 7: 10^21 (compound)
'quatrilião', // 8: 10^24
]
const SCALE_WORDS_PLURAL = [
'', // 0 unused
THOUSAND, // 1: mil (same)
'milhões', // 2: 10^6
'mil milhões', // 3: 10^9 (compound, same)
'biliões', // 4: 10^12
'mil biliões', // 5: 10^15 (compound, same)
'triliões', // 6: 10^18
'mil triliões', // 7: 10^21 (compound, same)
'quatriliões', // 8: 10^24
]
// Scale ordinal words (long scale for pt-PT). 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', 'mil milionésimo', 'bilionésimo', 'mil bilionésimo', 'trilionésimo']
// Supported magnitude ceilings (checked at the public entry points). Cardinal
// scale words reach index SCALE_WORDS_SINGULAR.length-1 (quatriliã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)
// Insert "e" before remainder if it doesn't start with hundreds (< 100)
if (!remainderResult.startsWithHundreds) {
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
// Segments stored least-significant first (index 0 = ones, 1 = thousands, etc.)
const segments = []
let temp = n
while (temp > 0n) {
segments.push(Number(temp % 1000n))
temp = temp / 1000n
}
// Find the first non-zero segment index (lowest scale with value)
let firstNonZeroIdx = 0
for (let i = 0; i < segments.length; i++) {
if (segments[i] !== 0) {
firstNonZeroIdx = i
break
}
}
// Build result string directly
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)
// Add "e" before final segment if previous was scale and this doesn't start with hundreds
if (result && isLastSegment && prevWasScale && !segmentResult.startsWithHundreds) {
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 - use scale arrays
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.
*
* This is the main public API. It accepts any valid numeric input
* (number, string, or bigint) and handles parsing internally.
* @param {number | string | bigint} value - The numeric value to convert
* @returns {string} The number in Portuguese words
* @throws {TypeError} If value is not a valid numeric type
* @throws {Error} If value is not a valid number format
* @example
* toCardinal(21) // 'vinte e um'
* toCardinal(100) // 'cem'
* toCardinal(1000000) // 'um milhão'
*/
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
* @example
* toOrdinal(1) // 'primeiro'
* toOrdinal(21) // 'vigésimo primeiro'
* toOrdinal(100) // 'centésimo'
*/
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 euros and cents
*/
/** @type {Required<CurrencyOptions>} */
export const currencyDefaults = { and: true }
/**
* Converts a number to Portuguese currency words (Euro).
* @param {number | string | bigint} value - The amount to convert
* @param {CurrencyOptions} [options] Currency formatting options
* @returns {string} Portuguese currency words
* @example
* toCurrency(42.50) // 'quarenta e dois euros e cinquenta cêntimos'
* toCurrency(1) // 'um euro'
* toCurrency(0.01) // 'um cêntimo'
*/
function toCurrency(value, options) {
const { isNegative, dollars: euros, cents } = parseCurrencyValue(value)
checkMax(euros, currencyMax)
const { and } = resolveOptions(options, currencyDefaults)
let result = ''
if (isNegative) {
result = NEGATIVE + ' '
}
const hasEuros = euros > 0n
const hasCents = cents > 0n
if (!hasEuros && !hasCents) {
return ZERO + ' ' + EUROS
}
if (hasEuros) {
const euroWords = integerToWords(euros)
const euroUnit = euros === 1n ? EURO : EUROS
result += euroWords + ' ' + euroUnit
}
if (hasCents) {
if (hasEuros) {
result += and ? ' e ' : ' '
}
const centWords = integerToWords(cents)
const centUnit = cents === 1n ? CENTIMO : CENTIMOS
result += centWords + ' ' + centUnit
}
return result
}
// ============================================================================
// Public API
// ============================================================================
export { toCardinal, toOrdinal, toCurrency }