UNPKG

n2words

Version:

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

525 lines (451 loc) 16 kB
/** * French (Belgium) language converter * * CLDR: fr-BE | French as used in Belgium * * Belgian French differences from standard French: * - septante (70) instead of soixante-dix * - nonante (90) instead of quatre-vingt-dix * - Keeps quatre-vingts (80) like standard French * - Uses "septante et un" (71), "nonante et un" (91) */ 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 // ============================================================================ const ONES = ['', 'un', 'deux', 'trois', 'quatre', 'cinq', 'six', 'sept', 'huit', 'neuf'] const TEENS = ['dix', 'onze', 'douze', 'treize', 'quatorze', 'quinze', 'seize', 'dix-sept', 'dix-huit', 'dix-neuf'] const TENS = ['', '', 'vingt', 'trente', 'quarante', 'cinquante', 'soixante', 'septante', 'quatre-vingt', 'nonante'] // Scale words (long scale with -ard forms) const SCALES = ['million', 'billion', 'trillion', 'quadrillion'] const SCALES_ARD = ['milliard', 'billiard', 'trilliard', 'quadrilliard'] // Supported magnitude ceiling (checked at the public entry points). Long scale: // each SCALES entry has a base and an "-ard" form spanning two segment groups, // so with the units group that's 2 * SCALES.length + 2 groups of 3 digits. // Cardinals must stay below 10^30; ordinals and currency build on the cardinal, // so they share the same ceiling. export const cardinalMax = longScale(SCALES.length) export const ordinalMax = longScale(SCALES.length) export const currencyMax = longScale(SCALES.length) const THOUSAND = 'mille' const HUNDRED = 'cent' const ZERO = 'zéro' const NEGATIVE = 'moins' const DECIMAL_SEP = 'virgule' // ============================================================================ // Ordinal Vocabulary // ============================================================================ // Ordinal suffix const ORDINAL_SUFFIX = 'ième' // Special ordinals const PREMIER = 'premier' // ============================================================================ // Currency Vocabulary (Euro) // ============================================================================ const EURO = 'euro' const EUROS = 'euros' const CENTIME = 'centime' const CENTIMES = 'centimes' // ============================================================================ // Segment Building // ============================================================================ /** * Builds the words for a 0-999 segment. * @param {number} n - Segment value (0-999) * @returns {{word: string, endsWithCents: boolean, endsWithVingts: boolean}} Segment words and flags */ function buildSegment(n) { if (n === 0) return { word: '', endsWithCents: false, endsWithVingts: false } const tensOnes = n % 100 const hundreds = Math.trunc(n / 100) const parts = [] let endsWithCents = false let endsWithVingts = false // Hundreds if (hundreds > 0) { if (hundreds === 1) { parts.push(HUNDRED) } else { if (tensOnes === 0) { parts.push(ONES[hundreds] + ' ' + HUNDRED + 's') endsWithCents = true } else { parts.push(ONES[hundreds] + ' ' + HUNDRED) } } } // Tens and ones - Belgian pattern if (tensOnes === 0) { // Just hundreds } else if (tensOnes < 10) { parts.push(ONES[tensOnes]) } else if (tensOnes < 17) { parts.push(TEENS[tensOnes - 10]) } else if (tensOnes < 20) { parts.push(TEENS[tensOnes - 10]) } else if (tensOnes < 70) { // 20-69: standard pattern const t = Math.trunc(tensOnes / 10) const o = tensOnes % 10 if (o === 0) { parts.push(TENS[t]) } else if (o === 1) { parts.push(TENS[t] + ' et ' + ONES[1]) } else { parts.push(TENS[t] + '-' + ONES[o]) } } else if (tensOnes < 80) { // 70-79: septante pattern (Belgian) const o = tensOnes % 10 if (o === 0) { parts.push('septante') } else if (o === 1) { parts.push('septante et ' + ONES[1]) } else { parts.push('septante-' + ONES[o]) } } else if (tensOnes === 80) { // 80: quatre-vingts (same as standard) parts.push('quatre-vingts') endsWithVingts = true } else if (tensOnes < 90) { // 81-89: quatre-vingt-X (same as standard) const remainder = tensOnes - 80 parts.push('quatre-vingt-' + ONES[remainder]) } else { // 90-99: nonante pattern (Belgian) const o = tensOnes % 10 if (o === 0) { parts.push('nonante') } else if (o === 1) { parts.push('nonante et ' + ONES[1]) } else { parts.push('nonante-' + ONES[o]) } } return { word: parts.join(' '), endsWithCents, endsWithVingts } } // ============================================================================ // Helper Functions // ============================================================================ /** * Returns the scale word (mille, million, milliard, ...) for a scale index. * @param {number} scaleIndex - Scale group index * @param {bigint} segment - Segment value used to decide pluralization * @returns {string} The scale word */ function getScaleWord(scaleIndex, segment) { if (scaleIndex === 1) return THOUSAND if (scaleIndex % 2 === 0) { const arrayIndex = (scaleIndex / 2) - 1 const baseWord = SCALES[arrayIndex] if (!baseWord) return '' return segment > 1n ? baseWord + 's' : baseWord } else { const arrayIndex = ((scaleIndex - 1) / 2) - 1 const ardWord = SCALES_ARD[arrayIndex] if (!ardWord) return THOUSAND return segment > 1n ? ardWord + 's' : ardWord } } // ============================================================================ // Conversion Functions // ============================================================================ /** * Converts a non-negative integer to Belgian French cardinal words. * @param {bigint} n - Non-negative integer * @param {boolean} [withHyphen] - Use hyphens between words * @returns {string} The integer in Belgian French words */ function integerToWords(n, withHyphen = false) { if (n === 0n) return ZERO if (n < 1000n) { const { word } = buildSegment(Number(n)) return withHyphen ? word.replace(/ /g, '-') : word } if (n < 1_000_000n) { const thousands = Number(n / 1000n) const remainder = Number(n % 1000n) let result if (thousands === 1) { result = THOUSAND } else { const { word: thousandsWord, endsWithCents, endsWithVingts } = buildSegment(thousands) let adjustedWord = thousandsWord if (endsWithCents || endsWithVingts) { adjustedWord = thousandsWord.slice(0, -1) } result = adjustedWord + (withHyphen ? '-' : ' ') + THOUSAND } if (remainder > 0) { const { word: remainderWord } = buildSegment(remainder) result += (withHyphen ? '-' : ' ') + remainderWord } if (withHyphen) { result = result.replace(/ /g, '-') } return result } return buildLargeNumberWords(n, withHyphen) } /** * Builds words for large integers (>= 1,000,000) using scale groups. * @param {bigint} n - Integer value (>= 1,000,000) * @param {boolean} withHyphen - Use hyphens between words * @returns {string} The integer in Belgian French words */ function buildLargeNumberWords(n, withHyphen) { const numStr = n.toString() const len = numStr.length const segments = [] const segmentSize = 3 const remainderLen = len % segmentSize let pos = 0 if (remainderLen > 0) { segments.push(Number(numStr.slice(0, remainderLen))) pos = remainderLen } while (pos < len) { segments.push(Number(numStr.slice(pos, pos + segmentSize))) pos += segmentSize } const parts = [] let scaleIndex = segments.length - 1 for (let i = 0; i < segments.length; i++) { const segment = segments[i] if (segment !== 0) { const scaleWord = scaleIndex > 0 ? getScaleWord(scaleIndex, BigInt(segment)) : '' const { word: segWords, endsWithCents, endsWithVingts } = buildSegment(segment) if (scaleIndex === 0) { parts.push(segWords) } else if (scaleIndex === 1) { if (segment === 1) { parts.push(THOUSAND) } else { let adjustedWord = segWords if (endsWithCents || endsWithVingts) { adjustedWord = segWords.slice(0, -1) } parts.push(adjustedWord) parts.push(scaleWord) } } else { parts.push(segWords) parts.push(scaleWord) } } scaleIndex-- } const sep = withHyphen ? '-' : ' ' let result = parts.join(sep) if (withHyphen) { result = result.replace(/ /g, '-') } return result } /** * Converts the decimal part digits to words (leading zeros spoken as zéro). * @param {string} decimalPart - Decimal digits as a string * @param {boolean} withHyphen - Use hyphens between words * @returns {string} The decimal part in Belgian French words */ function decimalPartToWords(decimalPart, withHyphen) { let result = '' const sep = withHyphen ? '-' : ' ' let i = 0 while (i < decimalPart.length && decimalPart[i] === '0') { if (result) result += sep result += ZERO i++ } const remainder = decimalPart.slice(i) if (remainder) { if (result) result += sep result += integerToWords(BigInt(remainder), withHyphen) } return result } /** * @typedef {object} CardinalOptions * @property {boolean} [withHyphenSeparator] - Use hyphens between words */ /** @type {Required<CardinalOptions>} */ export const cardinalDefaults = { withHyphenSeparator: false } /** * Converts a numeric value to Belgian French words. * @param {number | string | bigint} value - The numeric value to convert * @param {CardinalOptions} [options] - Optional configuration * @returns {string} The number in Belgian French words */ 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 { withHyphenSeparator } = resolveOptions(options, cardinalDefaults) let result = '' const sep = withHyphenSeparator ? '-' : ' ' if (isNegative) { result = NEGATIVE + sep } result += integerToWords(integerPart, withHyphenSeparator) if (decimalPart) { result += sep + DECIMAL_SEP + sep + decimalPartToWords(decimalPart, withHyphenSeparator) } return result } // ============================================================================ // ORDINAL: toOrdinal(value) // ============================================================================ /** * Converts a cardinal number word to its ordinal form. * Rules: * - 1 → premier (special case) * - Drop final -e before adding -ième (quatre → quatrième) * - cinq → cinquième (add -u- before -ième) * - neuf → neuvième (f → v before -ième) * @param {string} cardinalWord - Cardinal word to convert * @returns {string} Ordinal form */ function cardinalToOrdinal(cardinalWord) { // Handle special endings if (cardinalWord.endsWith('cinq')) { // cinq → cinquième (add 'u') return cardinalWord + 'u' + ORDINAL_SUFFIX } if (cardinalWord.endsWith('neuf')) { // neuf → neuvième (f → v) return cardinalWord.slice(0, -1) + 'v' + ORDINAL_SUFFIX } // Drop plural -s from cents/vingts/millions/etc. (quatre-vingts → quatre-vingtième) // Note: "trois", "six" also end in s but that's not a plural if (cardinalWord.endsWith('cents') || cardinalWord.endsWith('vingts') || cardinalWord.endsWith('millions') || cardinalWord.endsWith('milliards') || cardinalWord.endsWith('billions') || cardinalWord.endsWith('billiards') || cardinalWord.endsWith('trillions') || cardinalWord.endsWith('trilliards') || cardinalWord.endsWith('quadrillions') || cardinalWord.endsWith('quadrilliards')) { return cardinalWord.slice(0, -1) + ORDINAL_SUFFIX } // Drop final -e before adding -ième (quatre → quatrième) if (cardinalWord.endsWith('e')) { return cardinalWord.slice(0, -1) + ORDINAL_SUFFIX } // Default: just add -ième return cardinalWord + ORDINAL_SUFFIX } /** * Converts a positive integer to Belgian French ordinal words. * @param {bigint} n - Positive integer * @returns {string} Belgian French ordinal words */ function integerToOrdinal(n) { // Special case: 1 → premier if (n === 1n) { return PREMIER } // Get cardinal form and convert to ordinal const cardinalWord = integerToWords(n, false) return cardinalToOrdinal(cardinalWord) } /** * Converts a numeric value to Belgian French ordinal words. * * Belgian French ordinals: premier (1st), then cardinal + ième. * Special rules: quatre→quatrième, cinq→cinquième, neuf→neuvième. * @param {number | string | bigint} value - The numeric value to convert (positive integer) * @returns {string} The number as ordinal words * @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) // 'premier' * toOrdinal(2) // 'deuxième' * toOrdinal(70) // 'septantième' * toOrdinal(90) // 'nonantième' */ function toOrdinal(value) { const integerPart = parseOrdinalValue(value) checkMax(integerPart, ordinalMax) return integerToOrdinal(integerPart) } // ============================================================================ // CURRENCY: toCurrency(value, options?) // ============================================================================ /** * @typedef {object} CurrencyOptions * @property {boolean} [and] - Use "et" between euros and centimes */ /** @type {Required<CurrencyOptions>} */ export const currencyDefaults = { and: true } /** * Converts a numeric value to Belgian French currency words (Euro). * @param {number | string | bigint} value - The currency amount to convert * @param {CurrencyOptions} [options] - Optional configuration * @returns {string} The amount in Belgian French 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) // 'quarante-deux euros et cinquante centimes' * toCurrency(1) // 'un euro' * toCurrency(0.99) // 'nonante-neuf centimes' * toCurrency(0.01) // 'un centime' * toCurrency(42.50, { and: false }) // 'quarante-deux euros cinquante centimes' */ function toCurrency(value, options) { const { isNegative, dollars: euros, cents: centimes } = parseCurrencyValue(value) checkMax(euros, currencyMax) const { and: useAnd } = resolveOptions(options, currencyDefaults) // Build result let result = '' if (isNegative) result = NEGATIVE + ' ' // Euros part if (euros > 0n || centimes === 0n) { result += integerToWords(euros, false) // In French, 0 and 1 are singular: "zéro euro", "un euro" result += ' ' + (euros <= 1n ? EURO : EUROS) } // Centimes part if (centimes > 0n) { if (euros > 0n) { result += useAnd ? ' et ' : ' ' } result += integerToWords(centimes, false) result += ' ' + (centimes === 1n ? CENTIME : CENTIMES) } return result } // ============================================================================ // Exports // ============================================================================ export { toCardinal, toOrdinal, toCurrency }