UNPKG

n2words

Version:

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

540 lines (463 loc) 17.5 kB
/** * Canadian English language converter * * CLDR: en-CA | English as used in Canada * * Exports: * - toCardinal(value, options?) - Cardinal numbers: 42 → "forty-two" * - toOrdinal(value) - Ordinal numbers: 42 → "forty-second" * - toCurrency(value, options?) - Currency: 42.50 → "forty-two dollars and fifty cents" * * Canadian English conventions: * - Follows British English style for number words * - "and" after hundreds: "one hundred and twenty-three" (default) * - "and" before final segment: "one million and one" (default) * - Hyphenated tens-ones: "twenty-one", "forty-two" * - Western numbering system (short scale: billion = 10^9) * - Optional hundred-pairing: 1500 → "fifteen hundred" (colloquial) * - Optional "and" omission: 101 → "one hundred one" (American style) * - Currency: Canadian Dollar (CAD) - dollar/dollars, cent/cents */ 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 // ============================================================================ // Cardinal vocabulary const ONES = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine'] const TEENS = ['ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen'] const TENS = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'] const SCALES = [ 'thousand', 'million', 'billion', 'trillion', 'quadrillion', 'quintillion', 'sextillion', 'septillion', 'octillion', 'nonillion', 'decillion', 'undecillion', 'duodecillion', 'tredecillion', 'quattuordecillion', 'quindecillion', 'sexdecillion', 'septendecillion', 'octodecillion', 'novemdecillion', 'vigintillion', ] export const cardinalMax = western(SCALES.length) export const ordinalMax = western(SCALES.length) export const currencyMax = western(SCALES.length) const HUNDRED = 'hundred' const ZERO = 'zero' const NEGATIVE = 'minus' const DECIMAL_SEP = 'point' // Ordinal vocabulary const ORDINAL_ONES = ['', 'first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth', 'ninth'] const ORDINAL_TEENS = ['tenth', 'eleventh', 'twelfth', 'thirteenth', 'fourteenth', 'fifteenth', 'sixteenth', 'seventeenth', 'eighteenth', 'nineteenth'] const ORDINAL_TENS = ['', '', 'twentieth', 'thirtieth', 'fortieth', 'fiftieth', 'sixtieth', 'seventieth', 'eightieth', 'ninetieth'] // Currency vocabulary (Canadian Dollar) const DOLLAR = 'dollar' const DOLLARS = 'dollars' const CENT = 'cent' const CENTS = 'cents' // ============================================================================ // SHARED HELPERS // ============================================================================ // Reusable result object to avoid allocation per call const segmentResult = { word: '', hasHundred: false } /** * Builds words for a 0-999 segment. * @param {number} n - Number 0-999 * @param {boolean} useAnd - Whether to use "and" after hundreds * @returns {{ word: string, hasHundred: boolean }} The segment words and whether a hundreds place is present */ function buildSegment(n, useAnd) { if (n === 0) { segmentResult.word = '' segmentResult.hasHundred = false return segmentResult } const ones = n % 10 const tens = Math.trunc(n / 10) % 10 const hundreds = Math.trunc(n / 100) // Build tens-ones part first let tensOnes = '' if (tens === 1) { tensOnes = TEENS[ones] } else if (tens >= 2) { tensOnes = ones > 0 ? TENS[tens] + '-' + ONES[ones] : TENS[tens] } else if (ones > 0) { tensOnes = ONES[ones] } // Hundreds place if (hundreds > 0) { if (tensOnes) { const connector = useAnd ? ' and ' : ' ' segmentResult.word = ONES[hundreds] + ' ' + HUNDRED + connector + tensOnes } else { segmentResult.word = ONES[hundreds] + ' ' + HUNDRED } segmentResult.hasHundred = true } else { segmentResult.word = tensOnes segmentResult.hasHundred = false } return segmentResult } // ============================================================================ // CARDINAL: toCardinal(value, options?) // ============================================================================ /** * Converts a non-negative integer to English words. * @param {bigint} n - Non-negative integer to convert * @param {boolean} hundredPairing - Use hundred-pairing for 1100-9999 * @param {boolean} useAnd - Use "and" after hundreds and before final segment * @returns {string} English words */ function integerToWords(n, hundredPairing, useAnd) { if (n === 0n) return ZERO // Fast path: numbers < 1000 if (n < 1000n) { return buildSegment(Number(n), useAnd).word } // Hundred-pairing: 1100-9999 → "eleven hundred" to "ninety-nine hundred ninety-nine" if (hundredPairing && n >= 1100n && n <= 9999n) { const num = Number(n) const highPart = Math.trunc(num / 100) const lowPart = num % 100 const { word: highWord } = buildSegment(highPart, useAnd) let result = highWord + ' ' + HUNDRED if (lowPart > 0) { const { word: lowWord } = buildSegment(lowPart, useAnd) // Add "and" before remainder if useAnd is true if (useAnd) { result += ' and' } result += ' ' + lowWord } return result } // Fast path: numbers < 1,000,000 if (n < 1_000_000n) { const thousands = Number(n / 1000n) const remainder = Number(n % 1000n) const { word: thousandsWord } = buildSegment(thousands, useAnd) let result = thousandsWord + ' ' + SCALES[0] if (remainder > 0) { const { word: remainderWord, hasHundred } = buildSegment(remainder, useAnd) // Add "and" before remainder if useAnd is true and remainder has no hundred if (useAnd && !hasHundred) { result += ' and' } result += ' ' + remainderWord } return result } // For numbers >= 1,000,000, use scale decomposition return buildLargeNumberWords(n, useAnd) } /** * Builds words for numbers >= 1,000,000. * Uses BigInt division for faster segment extraction. * @param {bigint} n - Number >= 1,000,000 * @param {boolean} useAnd - Use "and" after hundreds and before final segment * @returns {string} English words */ function buildLargeNumberWords(n, useAnd) { // Extract segments using BigInt division // Segments are 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 (smallest index) non-zero segment - this is processed last let firstNonZeroIdx = -1 if (useAnd) { for (let i = 0; i < segments.length; i++) { if (segments[i] !== 0) { firstNonZeroIdx = i break } } } // Build result string (process from most-significant to least) let result = '' let prevWasScale = false for (let i = segments.length - 1; i >= 0; i--) { const segment = segments[i] if (segment === 0) continue const { word, hasHundred } = buildSegment(segment, useAnd) const isLastSegment = (i === firstNonZeroIdx) // Add "and" only before FINAL segment if it follows scale and doesn't have hundred if (useAnd && result && isLastSegment && prevWasScale && !hasHundred) { result += ' and' } // Add segment word if (result) result += ' ' result += word // Add scale word (i=0 is units, i=1 is thousands, etc.) if (i > 0) { result += ' ' + SCALES[i - 1] prevWasScale = true } else { prevWasScale = false } } return result } /** * Converts decimal digits to English words. * @param {string} decimalPart - Decimal digits (without the point) * @param {boolean} useAnd - Use "and" in number conversion * @returns {string} English words for decimal part */ function decimalPartToWords(decimalPart, useAnd) { 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), false, useAnd) } return result } /** * @typedef {object} CardinalOptions * @property {boolean} [hundredPairing] - Use hundred-pairing for 1100-9999 (e.g., "fifteen hundred" instead of "one thousand five hundred") * @property {boolean} [and] - Use "and" after hundreds and before final small numbers (default: true, Canadian/British style) */ /** @type {Required<CardinalOptions>} */ export const cardinalDefaults = { hundredPairing: false, and: true } /** * Converts a numeric value to Canadian English 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 * @param {CardinalOptions} [options] - Optional configuration * @returns {string} The number in Canadian English words * @throws {TypeError} If value is not a valid numeric type * @throws {Error} If value is not a valid number format * @example * toCardinal(42) // 'forty-two' * toCardinal(101) // 'one hundred and one' * toCardinal(101, { and: false }) // 'one hundred one' * toCardinal(1500) // 'one thousand five hundred' * toCardinal(1500, { hundredPairing: true }) // 'fifteen hundred' */ 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) // Extract options with defaults (Canadian English uses "and" like British English) const { hundredPairing, and: useAnd } = resolveOptions(options, cardinalDefaults) let result = '' if (isNegative) { result = NEGATIVE + ' ' } result += integerToWords(integerPart, hundredPairing, useAnd) if (decimalPart) { result += ' ' + DECIMAL_SEP + ' ' + decimalPartToWords(decimalPart, useAnd) } return result } // ============================================================================ // ORDINAL: toOrdinal(value) // ============================================================================ /** * Builds ordinal words for a 0-999 segment (final segment only). * Returns ordinal form: "first", "twenty-third", "one hundred forty-fifth" * @param {number} n - Number 0-999 * @returns {string} Ordinal words for this segment */ function buildOrdinalSegment(n) { const ones = n % 10 const tens = Math.trunc(n / 10) % 10 const hundreds = Math.trunc(n / 100) // Build ordinal for tens-ones portion let tensOnesOrdinal = '' if (tens === 1) { // Teens: 10-19 → "tenth" through "nineteenth" tensOnesOrdinal = ORDINAL_TEENS[ones] } else if (tens >= 2) { if (ones > 0) { // Compound: "twenty-first", "thirty-second", etc. tensOnesOrdinal = TENS[tens] + '-' + ORDINAL_ONES[ones] } else { // Round tens: "twentieth", "thirtieth", etc. tensOnesOrdinal = ORDINAL_TENS[tens] } } else if (ones > 0) { // Single digit: "first", "second", etc. tensOnesOrdinal = ORDINAL_ONES[ones] } // Hundreds place if (hundreds > 0) { if (tensOnesOrdinal) { // "one hundred twenty-first" return ONES[hundreds] + ' ' + HUNDRED + ' ' + tensOnesOrdinal } else { // "one hundredth", "two hundredth", etc. return ONES[hundreds] + ' hundredth' } } return tensOnesOrdinal } /** * Converts a positive integer to ordinal words. * Generates ordinals directly without string manipulation. * @param {bigint} n - Positive integer to convert * @returns {string} Ordinal English words */ function integerToOrdinal(n) { // Fast path: numbers < 1000 if (n < 1000n) { return buildOrdinalSegment(Number(n)) } // Fast path: numbers < 1,000,000 if (n < 1_000_000n) { const thousands = Number(n / 1000n) const remainder = Number(n % 1000n) if (remainder === 0) { // Exact thousands: "one thousandth", "five thousandth" return buildSegment(thousands, false).word + ' ' + SCALES[0] + 'th' } // Has remainder: cardinal thousands + ordinal remainder const { word: thousandsWord } = buildSegment(thousands, false) return thousandsWord + ' ' + SCALES[0] + ' ' + buildOrdinalSegment(remainder) } // For numbers >= 1,000,000, use scale decomposition return buildLargeOrdinal(n) } /** * Builds ordinal words for numbers >= 1,000,000. * All segments except the final one are cardinal; final segment is ordinal. * @param {bigint} n - Number >= 1,000,000 * @returns {string} Ordinal English words */ function buildLargeOrdinal(n) { // Extract segments (least-significant first) const segments = [] let temp = n while (temp > 0n) { segments.push(Number(temp % 1000n)) temp = temp / 1000n } // Find the lowest non-zero segment (this gets ordinal treatment) let lowestNonZeroIdx = 0 for (let i = 0; i < segments.length; i++) { if (segments[i] !== 0) { lowestNonZeroIdx = i break } } // Build result (most-significant to least) let result = '' for (let i = segments.length - 1; i >= 0; i--) { const segment = segments[i] if (segment === 0) continue const isLowestSegment = (i === lowestNonZeroIdx) if (result) result += ' ' if (isLowestSegment) { // Final non-zero segment gets ordinal treatment if (i === 0) { // Units position: use ordinal segment result += buildOrdinalSegment(segment) } else { // Scale position with no remainder below: "one millionth" result += buildSegment(segment, false).word + ' ' + SCALES[i - 1] + 'th' } } else { // Non-final segments are cardinal result += buildSegment(segment, false).word if (i > 0) { result += ' ' + SCALES[i - 1] } } } return result } /** * Converts a numeric value to Canadian English ordinal words. * @param {number | string | bigint} value - The numeric value to convert (must be a positive integer) * @returns {string} The number as ordinal words (e.g., "first", "forty-second") * @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) // 'first' * toOrdinal(2) // 'second' * toOrdinal(3) // 'third' * toOrdinal(21) // 'twenty-first' * toOrdinal(42) // 'forty-second' * toOrdinal(100) // 'one hundredth' * toOrdinal(101) // 'one hundred first' * toOrdinal(1000) // 'one thousandth' */ function toOrdinal(value) { const integerPart = parseOrdinalValue(value) checkMax(integerPart, ordinalMax) return integerToOrdinal(integerPart) } // ============================================================================ // CURRENCY: toCurrency(value, options?) // ============================================================================ /** * @typedef {object} CurrencyOptions * @property {boolean} [and] - Use "and" between dollars and cents (e.g., "one dollar and fifty cents") */ /** @type {Required<CurrencyOptions>} */ export const currencyDefaults = { and: true } /** * Converts a numeric value to Canadian English currency words. * @param {number | string | bigint} value - The currency amount to convert * @param {CurrencyOptions} [options] - Optional configuration * @returns {string} The amount in Canadian English 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) // 'forty-two dollars and fifty cents' * toCurrency(1) // 'one dollar' * toCurrency(0.99) // 'ninety-nine cents' * toCurrency(42.50, { and: false }) // 'forty-two dollars fifty cents' */ function toCurrency(value, options) { const { isNegative, dollars, cents } = parseCurrencyValue(value) checkMax(dollars, currencyMax) const { and: useAnd } = resolveOptions(options, currencyDefaults) // Build result let result = '' if (isNegative) result = NEGATIVE + ' ' // Dollars part (show if non-zero, or if no cents) if (dollars > 0n || cents === 0n) { result += integerToWords(dollars, false, false) result += ' ' + (dollars === 1n ? DOLLAR : DOLLARS) } // Cents part if (cents > 0n) { if (dollars > 0n) { result += useAnd ? ' and ' : ' ' } result += integerToWords(cents, false, false) result += ' ' + (cents === 1n ? CENT : CENTS) } return result } // ============================================================================ // EXPORTS // ============================================================================ export { toCardinal, toOrdinal, toCurrency }