UNPKG

n2words

Version:

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

538 lines (464 loc) 17.2 kB
/** * Italian (Italy) language converter * * CLDR: it-IT | Italian as used in Italy * * Italian-specific rules: * - Concatenation without spaces within segments ("ventotto" not "venti otto") * - Phonetic vowel elision: "venti" + "otto" → "ventotto" * - Accent on final "tre" in compounds: "ventitré" * - mille/mila alternation for thousands * - Scale words: milione/milioni, miliardo/miliardi, etc. * - "e" connector before simple final remainder */ 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 { longScale } from './utils/scale.js' import { resolveOptions } from './utils/resolve-options.js' // ============================================================================ // Vocabulary (module-level constants) // ============================================================================ // Base vocabulary const ONES = ['', 'uno', 'due', 'tre', 'quattro', 'cinque', 'sei', 'sette', 'otto', 'nove'] const TEENS = ['dieci', 'undici', 'dodici', 'tredici', 'quattordici', 'quindici', 'sedici', 'diciassette', 'diciotto', 'diciannove'] const TENS = ['', '', 'venti', 'trenta', 'quaranta', 'cinquanta', 'sessanta', 'settanta', 'ottanta', 'novanta'] const HUNDREDS = ['', 'cento', 'duecento', 'trecento', 'quattrocento', 'cinquecento', 'seicento', 'settecento', 'ottocento', 'novecento'] // Pre-elided tens stems (drop final vowel before 'uno'/'otto') // vent- (from venti), trent- (from trenta), etc. const TENS_STEM = ['', '', 'vent', 'trent', 'quarant', 'cinquant', 'sessant', 'settant', 'ottant', 'novant'] const ZERO = 'zero' const NEGATIVE = 'meno' const DECIMAL_SEP = 'virgola' // Thousands const THOUSAND_SINGULAR = 'mille' const THOUSAND_PLURAL_SUFFIX = 'mila' // Scale word generation const SCALE_PREFIXES = ['m', 'b', 'tr', 'quadr', 'quint', 'sest', 'sett', 'ott', 'nov', 'dec'] // Long scale: each prefix yields an -ilione (10^6k) and an -iliardo (10^6k+3), // so the table spans 6 powers of ten per prefix; past it the scale word is empty. // Each form's maximum supported value (a bigint), derived from the prefix table // so it can't drift. Exported per form so they split cleanly and the gate/docs // read each as a fact. it-IT shares one ceiling across all three forms. export const cardinalMax = longScale(SCALE_PREFIXES.length) export const ordinalMax = longScale(SCALE_PREFIXES.length) export const currencyMax = longScale(SCALE_PREFIXES.length) // ============================================================================ // Ordinal Vocabulary // ============================================================================ // Irregular ordinals 1-10 (masculine form) const ORDINAL_ONES = ['', 'primo', 'secondo', 'terzo', 'quarto', 'quinto', 'sesto', 'settimo', 'ottavo', 'nono', 'decimo'] // Ordinal suffix for 11+ const ORDINAL_SUFFIX = 'esimo' // ============================================================================ // Currency Vocabulary (Euro) // ============================================================================ const EURO = 'euro' const CENTESIMO = 'centesimo' const CENTESIMI = 'centesimi' // ============================================================================ // Segment Building // ============================================================================ /** * Builds the segment word for a number 0-999. * Handles Italian phonetic elision inline (no regex). * * Elision rules: * - Tens ending in vowel + uno/otto → drop tens vowel: ventuno, ventotto * - Hundreds cento + otto/ottanta → centotto, centottanta (drop 'o') * - Final 'tre' in compounds becomes 'tré': ventitré, trentatré * @param {number} n - Number 0-999 to convert * @returns {string} Segment word */ function buildSegment(n) { if (n === 0) return '' const ones = n % 10 const tens = Math.trunc(n / 10) % 10 const hundreds = Math.trunc(n / 100) let result = '' // Hundreds if (hundreds > 0) { // Elision: *cento + otto/ottanta → *centotto/centottanta (drop final 'o') // Only applies when tens = 8 (ottanta) or tens = 0 and ones = 8 (otto) if (tens === 8 || (tens === 0 && ones === 8)) { // Remove final 'o' from hundreds: cento→cent, duecento→duecent, etc. result = HUNDREDS[hundreds].slice(0, -1) } else { result = HUNDREDS[hundreds] } } // Tens and ones if (tens === 0 && ones === 0) { // Nothing more (just hundreds) } else if (tens === 1) { // Teens: 10-19 result += TEENS[ones] } else if (tens >= 2) { // 20-99: handle elision for uno (1) and otto (8) if (ones === 1 || ones === 8) { // Use stem form: vent + uno = ventuno, vent + otto = ventotto result += TENS_STEM[tens] + ONES[ones] } else if (ones === 3) { // Final tre becomes tré result += TENS[tens] + 'tré' } else if (ones > 0) { result += TENS[tens] + ONES[ones] } else { result += TENS[tens] } } else if (ones > 0) { // 1-9 (tens === 0) if (ones === 3 && hundreds > 0) { // centotré, duecentotré, etc. result += 'tré' } else { result += ONES[ones] } } return result } /** * Builds segment word with "un" for scale context (millions, billions). * Same as buildSegment but returns "un" for 1 instead of "uno". * @param {number} n - Number 0-999 to convert * @returns {string} Segment word */ function buildSegmentForScale(n) { if (n === 0) return '' if (n === 1) return 'un' // "un milione" not "uno milione" return buildSegment(n) } /** * Builds thousands word for 1-999 thousand. * Handles elision: tre + mila = tremila (no accent), otto + mila = ottomila * @param {number} n - Number of thousands 1-999 * @returns {string} Thousands word */ function buildThousands(n) { if (n === 0) return '' if (n === 1) return THOUSAND_SINGULAR // "mille" // Build segment and append "mila" // Note: elision of segment ending vowel + 'mila' not needed (mila starts with 'm') // But we need to handle cases like "ottomila" (no double-o issue since we build directly) return buildSegment(n) + THOUSAND_PLURAL_SUFFIX } // ============================================================================ // Scale Word Functions // ============================================================================ /** * Gets singular scale word for index. * @param {number} scaleIndex - 2=million, 3=billion, etc. * @returns {string} Singular scale word */ function getScaleWordSingular(scaleIndex) { if (scaleIndex < 2) return '' const prefixIndex = Math.trunc((scaleIndex - 2) / 2) const isIardo = (scaleIndex - 2) % 2 === 1 const prefix = SCALE_PREFIXES[prefixIndex] if (!prefix) return '' return prefix + (isIardo ? 'iliardo' : 'ilione') } /** * Gets plural scale word for index. * @param {number} scaleIndex - 2=million, 3=billion, etc. * @returns {string} Plural scale word */ function getScaleWordPlural(scaleIndex) { if (scaleIndex < 2) return '' const prefixIndex = Math.trunc((scaleIndex - 2) / 2) const isIardo = (scaleIndex - 2) % 2 === 1 const prefix = SCALE_PREFIXES[prefixIndex] if (!prefix) return '' return prefix + (isIardo ? 'iliardi' : 'ilioni') } // ============================================================================ // Conversion Functions // ============================================================================ /** * Converts a non-negative integer to Italian words. * @param {bigint} n - Non-negative integer to convert * @returns {string} Italian words */ function integerToWords(n) { if (n === 0n) return ZERO // Fast path: numbers < 1000 if (n < 1000n) { return buildSegment(Number(n)) } // Fast path: numbers < 1,000,000 (thousands) if (n < 1_000_000n) { const thousands = Number(n / 1000n) const remainder = Number(n % 1000n) if (remainder === 0) { return buildThousands(thousands) } // Concatenate thousands + remainder return buildThousands(thousands) + buildSegment(remainder) } // For numbers >= 1,000,000, use scale decomposition return buildLargeNumberWords(n) } /** * Builds words for numbers >= 1,000,000. * @param {bigint} n - Number >= 1,000,000 * @returns {string} Italian words */ function buildLargeNumberWords(n) { const parts = [] let remaining = n // Find the highest scale let maxScale = 2 let testValue = 1_000_000n while (testValue * 1000n <= remaining) { testValue *= 1000n maxScale++ } // Process from highest scale down for (let scaleIndex = maxScale; scaleIndex >= 0; scaleIndex--) { const divisor = 1000n ** BigInt(scaleIndex) const segment = remaining / divisor remaining = remaining % divisor if (segment === 0n) continue const segNum = Number(segment) if (scaleIndex >= 2) { // Millions and above: "segment scaleWord" const segmentWords = buildSegmentForScale(segNum) const scaleWord = segment === 1n ? getScaleWordSingular(scaleIndex) : getScaleWordPlural(scaleIndex) parts.push(segmentWords + ' ' + scaleWord) } else if (scaleIndex === 1) { // Thousands parts.push(buildThousands(segNum)) } else { // Units (scaleIndex === 0): just the segment parts.push(buildSegment(segNum)) } } return joinPartsWithConnector(parts) } /** * Joins parts with Italian connector rules. * Uses "e" before simple (non-compound) final segment. * @param {string[]} parts - Parts to join * @returns {string} Joined string */ function joinPartsWithConnector(parts) { const len = parts.length if (len === 0) return '' if (len === 1) return parts[0] // Check if last part is "simple" (no space = no scale word) const lastPart = parts[len - 1] if (lastPart.indexOf(' ') === -1) { // Join all but last with space, then add "e" connector let result = parts[0] for (let i = 1; i < len - 1; i++) { result += ' ' + parts[i] } return result + ' e ' + lastPart } // Join with spaces let result = parts[0] for (let i = 1; i < len; i++) { result += ' ' + parts[i] } return result } /** * Converts decimal digits to Italian words. * @param {string} decimalPart - Decimal digits (without the point) * @returns {string} Italian 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 Italian 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 Italian words * @throws {TypeError} If value is not a valid numeric type * @throws {Error} If value is not a valid number format * @example * toCardinal(28) // 'ventotto' * toCardinal(23) // 'ventitré' * toCardinal(1000) // 'mille' * toCardinal(2000) // 'duemila' * toCardinal(1000000) // 'un milione' */ 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: toOrdinal(value) // ============================================================================ /** * Converts a cardinal word to ordinal form by dropping final vowel and adding -esimo. * @param {string} cardinalWord - Cardinal word to convert * @returns {string} Ordinal form */ function cardinalToOrdinal(cardinalWord) { // Handle accented 'é' at end (tré → tre + esimo = treesimo) if (cardinalWord.endsWith('é')) { return cardinalWord.slice(0, -1) + 'e' + ORDINAL_SUFFIX } // Handle "mila" ending (duemila → duemillesimo, not duemilesimo) // The ordinal of thousand is "millesimo" (from mille), not "milesimo" if (cardinalWord.endsWith('mila')) { return cardinalWord.slice(0, -4) + 'mill' + ORDINAL_SUFFIX } // Handle "mille" ending (mille → millesimo) if (cardinalWord.endsWith('mille')) { return cardinalWord.slice(0, -1) + ORDINAL_SUFFIX } // Handle -ardo/-ardi endings (miliardo → miliardiesimo) // These scale words get -iesimo not -esimo if (cardinalWord.endsWith('ardo') || cardinalWord.endsWith('ardi')) { return cardinalWord.slice(0, -1) + 'i' + ORDINAL_SUFFIX } // Drop final regular vowel before adding -esimo const lastChar = cardinalWord.slice(-1) if ('aeiouàèìòù'.includes(lastChar)) { return cardinalWord.slice(0, -1) + ORDINAL_SUFFIX } // If doesn't end in vowel, just add suffix return cardinalWord + ORDINAL_SUFFIX } /** * Converts a positive integer to Italian ordinal words. * @param {bigint} n - Positive integer * @returns {string} Italian ordinal words (masculine form) */ function integerToOrdinal(n) { // Special cases: 1-10 have irregular forms if (n <= 10n) { return ORDINAL_ONES[Number(n)] } // For 11+, convert cardinal and apply -esimo const cardinalWord = integerToWords(n) return cardinalToOrdinal(cardinalWord) } /** * Converts a numeric value to Italian ordinal words. * * Italian ordinals: primo, secondo, terzo... (1-10 irregular) * For 11+: cardinal word (drop final vowel) + -esimo * @param {number | string | bigint} value - The numeric value to convert (positive integer) * @returns {string} The number as ordinal words (masculine form) * @throws {TypeError} If value is not a valid numeric type * @throws {RangeError} If value is negative, zero, or has a decimal part * @example * toOrdinal(1) // 'primo' * toOrdinal(2) // 'secondo' * toOrdinal(11) // 'undicesimo' * toOrdinal(21) // 'ventunesimo' * toOrdinal(100) // 'centesimo' * toOrdinal(1000) // 'millesimo' */ function toOrdinal(value) { const integerPart = parseOrdinalValue(value) // Ordinals are derived from the cardinal speller, so they share its ceiling. checkMax(integerPart, ordinalMax) return integerToOrdinal(integerPart) } // ============================================================================ // CURRENCY: toCurrency(value, options?) // ============================================================================ /** * @typedef {object} CurrencyOptions * @property {boolean} [and] - Use "e" between euros and centesimi */ /** @type {Required<CurrencyOptions>} */ export const currencyDefaults = { and: true } /** * Converts a numeric value to Italian currency words (Euro). * @param {number | string | bigint} value - The currency amount to convert * @param {CurrencyOptions} [options] - Optional configuration * @returns {string} The amount in Italian 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) // 'quarantadue euro e cinquanta centesimi' * toCurrency(1) // 'un euro' * toCurrency(0.99) // 'novantanove centesimi' * toCurrency(0.01) // 'un centesimo' * toCurrency(42.50, { and: false }) // 'quarantadue euro cinquanta centesimi' */ function toCurrency(value, options) { const { isNegative, dollars: euros, cents: centesimi } = parseCurrencyValue(value) checkMax(euros, currencyMax) const { and: useAnd } = resolveOptions(options, currencyDefaults) // Build result let result = '' if (isNegative) result = NEGATIVE + ' ' // Euros part if (euros > 0n || centesimi === 0n) { // Use "un" for 1 euro instead of "uno" if (euros === 1n) { result += 'un' } else { result += integerToWords(euros) } // Euro is invariable (doesn't change for plural in Italian) result += ' ' + EURO } // Centesimi part if (centesimi > 0n) { if (euros > 0n) { result += useAnd ? ' e ' : ' ' } // Use "un" for 1 centesimo instead of "uno" if (centesimi === 1n) { result += 'un' } else { result += integerToWords(centesimi) } result += ' ' + (centesimi === 1n ? CENTESIMO : CENTESIMI) } return result } // ============================================================================ // Public API // ============================================================================ export { toCardinal, toOrdinal, toCurrency }