n2words
Version:
Convert numbers to words in 70+ languages with zero dependencies. Supports BigInt, decimals, and browser/Node.js environments.
490 lines (415 loc) • 16.2 kB
JavaScript
/**
* Romanian (Romania) language converter
*
* CLDR: ro-RO | Romanian as used in Romania
*
* Key features:
* - Gender agreement (unu/una, doi/două)
* - "De" preposition insertion for groups >= 20
* - Complex scale word handling (mie/mii, milion/milioane)
* - Feminine units for thousands
*/
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 = ['', 'unu', 'doi', 'trei', 'patru', 'cinci', 'șase', 'șapte', 'opt', 'nouă']
const ONES_FEM = ['', 'una', 'două', 'trei', 'patru', 'cinci', 'șase', 'șapte', 'opt', 'nouă']
const TEENS = ['zece', 'unsprezece', 'douăsprezece', 'treisprezece', 'paisprezece', 'cincisprezece', 'șaisprezece', 'șaptesprezece', 'optsprezece', 'nouăsprezece']
const TEENS_MASC = ['zece', 'unsprezece', 'doisprezece', 'treisprezece', 'paisprezece', 'cincisprezece', 'șaisprezece', 'șaptesprezece', 'optsprezece', 'nouăsprezece']
const TWENTIES = ['', '', 'douăzeci', 'treizeci', 'patruzeci', 'cincizeci', 'șaizeci', 'șaptezeci', 'optzeci', 'nouăzeci']
const HUNDREDS = ['', 'o sută', 'două sute', 'trei sute', 'patru sute', 'cinci sute', 'șase sute', 'șapte sute', 'opt sute', 'nouă sute']
const ZERO = 'zero'
const NEGATIVE = 'minus'
const DECIMAL_SEP = 'virgulă'
// Ordinal vocabulary (masculine forms)
const ORDINAL_ONES = ['', 'primul', 'al doilea', 'al treilea', 'al patrulea', 'al cincilea', 'al șaselea', 'al șaptelea', 'al optulea', 'al nouălea']
const ORDINAL_TEENS = ['al zecelea', 'al unsprezecelea', 'al doisprezecelea', 'al treisprezecelea', 'al paisprezecelea', 'al cincisprezecelea', 'al șaisprezecelea', 'al șaptesprezecelea', 'al optsprezecelea', 'al nouăsprezecelea']
const ORDINAL_TENS = ['', '', 'al douăzecilea', 'al treizecilea', 'al patruzecilea', 'al cincizecilea', 'al șaizecilea', 'al șaptezecilea', 'al optzecilea', 'al nouăzecilea']
const ORDINAL_HUNDRED = 'al sutălea'
const ORDINAL_THOUSAND = 'al miilea'
const ORDINAL_MILLION = 'al milionulea'
// Currency (Romanian Leu)
const LEU_SINGULAR = 'leu'
const LEU_PLURAL = 'lei'
const BAN_SINGULAR = 'ban'
const BAN_PLURAL = 'bani'
// Scale metadata: [singular, plural, article, feminine, needsDe]
// - singular: form for 1
// - plural: form for 2+
// - article: 'o' for feminine, 'un' for masculine
// - feminine: whether units should be feminine
// - needsDe: whether "de" is inserted for segment >= 20
const SCALE_META = [
{ singular: 'mie', plural: 'mii', article: 'o', feminine: true, needsDe: true },
{ singular: 'milion', plural: 'milioane', article: 'un', feminine: false, needsDe: true },
{ singular: 'miliard', plural: 'miliarde', article: 'un', feminine: false, needsDe: true },
{ singular: 'trilion', plural: 'trilioane', article: 'un', feminine: false, needsDe: true },
{ singular: 'cvadrilion', plural: 'cvadrilioane', article: 'un', feminine: false, needsDe: true },
{ singular: 'cvintilion', plural: 'cvintilioane', article: 'un', feminine: false, needsDe: true },
{ singular: 'sextilion', plural: 'sextilioane', article: 'un', feminine: false, needsDe: true },
{ singular: 'septilion', plural: 'septilioane', article: 'un', feminine: false, needsDe: true },
{ singular: 'octilion', plural: 'octilioane', article: 'un', feminine: false, needsDe: true },
]
// Cardinal scale words run thousand..octilion; past SCALE_META the builder drops
// the scale word. The ordinal spells its millions multiplier via spellUnder1000
// (0-999), so it overflows once that multiplier reaches 1000 — n must stay below 10^9.
export const cardinalMax = western(SCALE_META.length)
export const ordinalMax = bounded(9)
export const currencyMax = western(SCALE_META.length)
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Spells number under 100.
* @param {number} n - Number 0-99
* @param {boolean} [feminine] - Use feminine forms
* @param {boolean} [masculineTeens] - Use masculine teen forms
* @returns {string} The number 0-99 in words
*/
function spellUnder100(n, feminine = false, masculineTeens = false) {
if (n === 0) return ''
if (n < 10) {
return feminine ? ONES_FEM[n] : ONES_MASC[n]
}
if (n < 20) {
return masculineTeens ? TEENS_MASC[n - 10] : TEENS[n - 10]
}
const t = Math.trunc(n / 10)
const u = n % 10
if (u === 0) {
return TWENTIES[t]
}
const onesWord = feminine ? ONES_FEM[u] : ONES_MASC[u]
return TWENTIES[t] + ' și ' + onesWord
}
/**
* Spells number under 1000.
* @param {number} n - Number 0-999
* @param {boolean} [feminine] - Use feminine forms
* @param {boolean} [masculineTeens] - Use masculine teen forms
* @returns {string} The number 0-999 in words
*/
function spellUnder1000(n, feminine = false, masculineTeens = false) {
if (n === 0) return ''
if (n < 100) return spellUnder100(n, feminine, masculineTeens)
const h = Math.trunc(n / 100)
const r = n % 100
const hundredWord = HUNDREDS[h]
if (r === 0) return hundredWord
return hundredWord + ' ' + spellUnder100(r, feminine, masculineTeens)
}
/**
* Builds scale word with proper pluralization and "de" insertion.
* Romanian always uses feminine forms (două, not doi) when counting scale words.
* @param {number} segment - Three-digit segment value (1-999)
* @param {number} scaleIndex - Scale position (1 = thousands, 2 = millions, ...)
* @returns {string} The scale phrase in words
*/
function buildScalePhrase(segment, scaleIndex) {
const meta = SCALE_META[scaleIndex - 1]
if (!meta) return spellUnder1000(segment, true)
if (segment === 1) {
return meta.article + ' ' + meta.singular
}
// Special case: 21 with scale words uses feminine "una"
if (segment === 21 && meta.needsDe) {
return 'douăzeci și una de ' + meta.plural
}
// Romanian always uses feminine when counting scale words (două milioane, not doi milioane)
const words = spellUnder1000(segment, true)
// "de" after >= 20
const needsDe = meta.needsDe && segment >= 20
const separator = needsDe ? ' de ' : ' '
return words + separator + meta.plural
}
// ============================================================================
// Conversion Functions
// ============================================================================
/**
* Converts a non-negative integer to Romanian words.
* @param {bigint} n - Non-negative integer to convert
* @param {string | {gender?: string}} gender - Gender for numbers ('feminine'/'masculine')
* @returns {string} Romanian words
*/
function integerToWords(n, gender) {
if (n === 0n) return ZERO
// Fast path: numbers < 1000
if (n < 1000n) {
const feminine = gender === 'feminine'
return spellUnder1000(Number(n), feminine)
}
return buildLargeNumberWords(n, gender)
}
/**
* Builds words for numbers >= 1000.
* Uses BigInt division for faster segment extraction.
* @param {bigint} n - Number >= 1000
* @param {string | {gender?: string}} gender - Gender for numbers ('feminine'/'masculine')
* @returns {string} Romanian words
*/
function buildLargeNumberWords(n, gender) {
// Extract segments using BigInt division (faster than string slicing)
// Segments stored least-significant first (index 0 = ones, 1 = thousands, etc.)
const segmentValues = []
let temp = n
while (temp > 0n) {
segmentValues.push(Number(temp % 1000n))
temp = temp / 1000n
}
// Build result string directly (avoid regex cleanup)
let result = ''
for (let i = segmentValues.length - 1; i >= 0; i--) {
const segment = segmentValues[i]
if (segment === 0) continue
let segmentWords
if (i === 0) {
// Units segment - use gender from options
const feminine = gender === 'feminine'
segmentWords = spellUnder1000(segment, feminine)
}
else {
// Scale segment
segmentWords = buildScalePhrase(segment, i)
}
if (result && segmentWords) {
result += ' ' + segmentWords
}
else if (segmentWords) {
result = segmentWords
}
}
return result
}
/**
* Converts decimal digits to Romanian words.
* Decimals always use masculine forms.
* @param {string} decimalPart - Decimal digits (without the point)
* @returns {string} Romanian words for decimal part
*/
function decimalPartToWords(decimalPart) {
let result = ''
let i = 0
// Handle leading zeros
while (i < decimalPart.length && decimalPart[i] === '0') {
if (result) result += ' '
result += ZERO
i++
}
// Convert remainder as a single number (masculine, with masculine teens)
const remainder = decimalPart.slice(i)
if (remainder) {
if (result) result += ' '
const n = BigInt(remainder)
if (n < 1000n) {
result += spellUnder1000(Number(n), false, true)
}
else {
result += integerToWords(n, { gender: 'masculine' })
}
}
return result
}
/**
* @typedef {object} CardinalOptions
* @property {('masculine'|'feminine')} [gender] - Gender for numbers
*/
/** @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 Romanian words.
* @param {number | string | bigint} value - The numeric value to convert
* @param {CardinalOptions} [options] - Conversion options
* @returns {string} The number in Romanian words
* @throws {TypeError} If value is not a valid numeric type
* @throws {Error} If value is not a valid number format
* @example
* toCardinal(21) // 'douăzeci și unu'
* toCardinal(1, { gender: 'feminine' }) // 'una'
* toCardinal(1000) // 'o mie'
*/
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)
let result = ''
if (isNegative) {
result = NEGATIVE + ' '
}
result += integerToWords(integerPart, gender)
if (decimalPart) {
result += ' ' + DECIMAL_SEP + ' ' + decimalPartToWords(decimalPart)
}
return result
}
// ============================================================================
// Ordinal Functions
// ============================================================================
/**
* Builds ordinal for tens and ones (0-99).
* @param {number} n - Number 0-99
* @returns {string} Ordinal word
*/
function buildOrdinalTensOnes(n) {
if (n === 0) return ''
if (n < 10) return ORDINAL_ONES[n]
if (n < 20) return ORDINAL_TEENS[n - 10]
const ones = n % 10
const tens = Math.trunc(n / 10)
if (ones === 0) {
return ORDINAL_TENS[tens]
}
// Compound: cardinal tens + ordinal ones (only last is ordinal)
return TWENTIES[tens] + ' și ' + ORDINAL_ONES[ones]
}
/**
* Converts a non-negative integer to Romanian ordinal words.
* @param {bigint} n - Non-negative integer to convert
* @returns {string} Romanian ordinal words
*/
function integerToOrdinal(n) {
if (n === 0n) return ''
if (n === 1n) return ORDINAL_ONES[1]
// Numbers < 100
if (n < 100n) {
return buildOrdinalTensOnes(Number(n))
}
// Numbers < 1000
if (n < 1000n) {
const hundreds = Number(n / 100n)
const remainder = Number(n % 100n)
if (remainder === 0) {
return ORDINAL_HUNDRED
}
return HUNDREDS[hundreds] + ' ' + buildOrdinalTensOnes(remainder)
}
// Numbers < 1,000,000
if (n < 1_000_000n) {
const thousands = Number(n / 1000n)
const remainder = Number(n % 1000n)
if (remainder === 0) {
if (thousands === 1) {
return ORDINAL_THOUSAND
}
return buildScalePhrase(thousands, 1) + ' ' + ORDINAL_THOUSAND
}
// Cardinal thousands + ordinal remainder
let result
if (thousands === 1) {
result = 'o mie'
}
else {
result = buildScalePhrase(thousands, 1)
}
if (remainder < 100) {
return result + ' ' + buildOrdinalTensOnes(remainder)
}
const remHundreds = Math.trunc(remainder / 100)
const remTensOnes = remainder % 100
if (remTensOnes === 0) {
return result + ' ' + ORDINAL_HUNDRED
}
return result + ' ' + HUNDREDS[remHundreds] + ' ' + buildOrdinalTensOnes(remTensOnes)
}
// Numbers >= 1,000,000
const millions = Number(n / 1_000_000n)
const remainder = n % 1_000_000n
if (remainder === 0n) {
if (millions === 1) {
return ORDINAL_MILLION
}
return buildScalePhrase(millions, 2) + ' ' + ORDINAL_MILLION
}
// Cardinal millions + ordinal remainder
let result
if (millions === 1) {
result = 'un milion'
}
else {
result = buildScalePhrase(millions, 2)
}
return result + ' ' + integerToOrdinal(remainder)
}
/**
* Converts a numeric value to Romanian ordinal words.
* @param {number | string | bigint} value - The numeric value to convert
* @returns {string} The ordinal in Romanian words
* @throws {TypeError} If value is not a valid numeric type
* @throws {Error} If value is not a positive integer
* @example
* toOrdinal(1) // 'primul'
* toOrdinal(21) // 'douăzeci și primul'
*/
function toOrdinal(value) {
const n = parseOrdinalValue(value)
checkMax(n, ordinalMax)
return integerToOrdinal(n)
}
// ============================================================================
// Currency Functions
// ============================================================================
/**
* Converts a numeric value to Romanian Leu currency words.
* @param {number | string | bigint} value - The numeric value to convert
* @returns {string} The currency in Romanian words
* @throws {TypeError} If value is not a valid numeric type
* @throws {Error} If value is not a valid number format
* @example
* toCurrency(1) // 'un leu'
* toCurrency(2.50) // 'doi lei cincizeci de bani'
*/
function toCurrency(value) {
const { isNegative, dollars, cents } = parseCurrencyValue(value)
checkMax(dollars, currencyMax)
const parts = []
if (isNegative) {
parts.push(NEGATIVE)
}
// Lei (masculine)
if (dollars > 0n || cents === 0n) {
if (dollars === 1n) {
parts.push('un ' + LEU_SINGULAR)
}
else {
const leuWord = integerToWords(dollars, 'masculine')
// Romanian inserts "de" before the noun for the CLDR `other` category:
// count >= 20 whose last two digits are 00 or 20-99 — "douăzeci de lei",
// "o sută de lei", but "o sută unu lei" (101) and no "de" below 20. The
// bani path below applies the same rule; buildScalePhrase uses a related
// per-segment predicate for scale words.
const m = dollars % 100n
const needsDe = dollars >= 20n && (m === 0n || m >= 20n)
parts.push(leuWord + (needsDe ? ' de ' : ' ') + LEU_PLURAL)
}
}
// Bani (masculine)
if (cents > 0n) {
const centNum = Number(cents)
if (centNum === 1) {
parts.push('un ' + BAN_SINGULAR)
}
else if (centNum >= 20) {
const banWord = spellUnder100(centNum, false)
parts.push(banWord + ' de ' + BAN_PLURAL)
}
else {
const banWord = spellUnder100(centNum, false)
parts.push(banWord + ' ' + BAN_PLURAL)
}
}
return parts.join(' ')
}
// ============================================================================
// Public API
// ============================================================================
export { toCardinal, toOrdinal, toCurrency }