n2words
Version:
Convert numbers to words in 70+ languages with zero dependencies. Supports BigInt, decimals, and browser/Node.js environments.
560 lines (486 loc) • 17.7 kB
JavaScript
/**
* French (France) language converter
*
* CLDR: fr-FR | French as used in France
*
* French-specific rules:
* - Vigesimal patterns: 70 = soixante-dix, 80 = quatre-vingts, 90 = quatre-vingt-dix
* - "et" conjunction: vingt et un (21), soixante et onze (71), but NOT quatre-vingt-un
* - Pluralization: "cents" loses 's' when followed by more digits
* - Long scale with -ard forms: milliard, billiard, trilliard
* - Omit "un" before mille
*/
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)
// ============================================================================
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']
// Scale words (even indices: million, billion, trillion, quadrillion)
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 segment word for 0-999.
* Returns object with { word, endsWithCents, endsWithVingts } for pluralization handling.
* @param {number} n - Segment value (0-999)
* @returns {{ word: string, endsWithCents: boolean, endsWithVingts: boolean }} Segment words and pluralization 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) {
if (tensOnes === 0) {
parts.push(HUNDRED)
}
else {
parts.push(HUNDRED)
}
}
else {
if (tensOnes === 0) {
// "deux cents", "trois cents" (with 's')
parts.push(ONES[hundreds] + ' ' + HUNDRED + 's')
endsWithCents = true
}
else {
// "deux cent", "trois cent" (no 's' when followed by more)
parts.push(ONES[hundreds] + ' ' + HUNDRED)
}
}
}
// Tens and ones - vigesimal pattern
if (tensOnes === 0) {
// Just hundreds, nothing more
}
else if (tensOnes < 10) {
// Single digit
parts.push(ONES[tensOnes])
}
else if (tensOnes < 17) {
// 10-16: regular teens
parts.push(TEENS[tensOnes - 10])
}
else if (tensOnes < 20) {
// 17-19: dix-sept, dix-huit, dix-neuf
parts.push(TEENS[tensOnes - 10])
}
else if (tensOnes < 70) {
// 20-69: standard tens + ones
const t = Math.trunc(tensOnes / 10)
const o = tensOnes % 10
if (o === 0) {
parts.push(TENS[t])
}
else if (o === 1) {
// "et un" for 21, 31, 41, 51, 61
parts.push(TENS[t] + ' et ' + ONES[1])
}
else {
parts.push(TENS[t] + '-' + ONES[o])
}
}
else if (tensOnes < 80) {
// 70-79: soixante-dix, soixante et onze, soixante-douze...
const remainder = tensOnes - 60
if (remainder === 11) {
// 71: soixante et onze
parts.push('soixante et onze')
}
else {
// 70, 72-79: soixante-dix, soixante-douze...
parts.push('soixante-' + TEENS[remainder - 10])
}
}
else if (tensOnes === 80) {
// 80: quatre-vingts (with 's')
parts.push('quatre-vingts')
endsWithVingts = true
}
else if (tensOnes < 100) {
// 81-99: quatre-vingt-un, quatre-vingt-dix...
const remainder = tensOnes - 80
if (remainder < 10) {
// 81-89
parts.push('quatre-vingt-' + ONES[remainder])
}
else {
// 90-99
parts.push('quatre-vingt-' + TEENS[remainder - 10])
}
}
// Join parts with space (between hundreds and rest)
return { word: parts.join(' '), endsWithCents, endsWithVingts }
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Gets scale word for French long scale with -ard pattern.
* @param {number} scaleIndex - Scale level (1 = thousand, 2 = million, etc.)
* @param {bigint} segment - Segment value for pluralization
* @returns {string} Scale word
*/
function getScaleWord(scaleIndex, segment) {
if (scaleIndex === 1) return THOUSAND
// Even indices (2, 4, 6, 8): million, billion, trillion, quadrillion
// Odd indices > 1 (3, 5, 7, 9): milliard, billiard, trilliard, quadrilliard
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 French words.
* @param {bigint} n - Non-negative integer to convert
* @param {boolean} withHyphen - Whether to use hyphen separators
* @returns {string} French words
*/
function integerToWords(n, withHyphen = false) {
if (n === 0n) return ZERO
// Fast path: numbers < 1000
if (n < 1000n) {
const { word } = buildSegment(Number(n))
return withHyphen ? word.replace(/ /g, '-') : 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) {
// "mille" not "un mille"
result = THOUSAND
}
else {
// Check if segment ends with "cents" or "vingts" - need to strip 's' before mille
const { word: thousandsWord, endsWithCents, endsWithVingts } = buildSegment(thousands)
let adjustedWord = thousandsWord
if (endsWithCents || endsWithVingts) {
adjustedWord = thousandsWord.slice(0, -1) // Remove trailing 's'
}
result = adjustedWord + (withHyphen ? '-' : ' ') + THOUSAND
}
if (remainder > 0) {
const { word: remainderWord } = buildSegment(remainder)
result += (withHyphen ? '-' : ' ') + remainderWord
}
if (withHyphen) {
result = result.replace(/ /g, '-')
}
return result
}
// For numbers >= 1,000,000, use scale decomposition
return buildLargeNumberWords(n, withHyphen)
}
/**
* Builds words for numbers >= 1,000,000.
* @param {bigint} n - Number >= 1,000,000
* @param {boolean} withHyphen - Whether to use hyphen separators
* @returns {string} French words
*/
function buildLargeNumberWords(n, withHyphen) {
const numStr = n.toString()
const len = numStr.length
// Build segments of 3 digits from right to left
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
}
// Convert segments to words
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) {
// Units segment
parts.push(segWords)
}
else if (scaleIndex === 1) {
// Thousands: "mille" not "un mille"
if (segment === 1) {
parts.push(THOUSAND)
}
else {
// Strip 's' from cents/vingts before mille
let adjustedWord = segWords
if (endsWithCents || endsWithVingts) {
adjustedWord = segWords.slice(0, -1)
}
parts.push(adjustedWord)
parts.push(scaleWord)
}
}
else {
// Million and above
parts.push(segWords)
parts.push(scaleWord)
}
}
scaleIndex--
}
const sep = withHyphen ? '-' : ' '
let result = parts.join(sep)
if (withHyphen) {
result = result.replace(/ /g, '-')
}
return result
}
/**
* Converts decimal digits to French words.
* @param {string} decimalPart - Decimal digits (without the point)
* @param {boolean} withHyphen - Whether to use hyphen separators
* @returns {string} French words for decimal part
*/
function decimalPartToWords(decimalPart, withHyphen) {
let result = ''
const sep = withHyphen ? '-' : ' '
// Handle leading zeros
let i = 0
while (i < decimalPart.length && decimalPart[i] === '0') {
if (result) result += sep
result += ZERO
i++
}
// Convert remainder as a single number
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 all words
*/
/** @type {Required<CardinalOptions>} */
export const cardinalDefaults = { withHyphenSeparator: false }
/**
* Converts a numeric value to French 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 French words
* @throws {TypeError} If value is not a valid numeric type
* @throws {Error} If value is not a valid number format
* @example
* toCardinal(21) // 'vingt et un'
* toCardinal(80) // 'quatre-vingts'
* toCardinal(1000000) // 'un million'
*/
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 French ordinal words.
* @param {bigint} n - Positive integer
* @returns {string} 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 French ordinal words.
*
* 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(4) // 'quatrième'
* toOrdinal(5) // 'cinquième'
* toOrdinal(9) // 'neuvième'
* toOrdinal(21) // 'vingt et unième'
* toOrdinal(100) // 'centième'
* toOrdinal(1000) // 'milliè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 French currency words (Euro).
* @param {number | string | bigint} value - The currency amount to convert
* @param {CurrencyOptions} [options] - Optional configuration
* @returns {string} The amount in 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) // 'quatre-vingt-dix-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
}
// ============================================================================
// Public API
// ============================================================================
export { toCardinal, toOrdinal, toCurrency }