lib/outils/conversion/nombreEnMots.js

/** @module lib/outils/conversion/nombreEnMots */
// cf https://chiffre-en-lettre.fr/
const dizaineNames = [
  '',
  '',
  'vingt',
  'trente',
  'quarante',
  'cinquante',
  'soixante',
  'soixante',
  'quatre-vingt',
  'quatre-vingt'
]

const uniteNames20 = [
  '',
  'un',
  'deux',
  'trois',
  'quatre',
  'cinq',
  'six',
  'sept',
  'huit',
  'neuf',
  'dix',
  'onze',
  'douze',
  'treize',
  'quatorze',
  'quinze',
  'seize',
  'dix-sept',
  'dix-huit',
  'dix-neuf'
]
const uniteNames = [
  // À priori on sert avant milliard|million|mille|cent => 0 et 1 donnent des chaînes vides,
  // il faudra gérer 'zéro' et 'un' dans le code
  '',
  '',
  'deux',
  'trois',
  'quatre',
  'cinq',
  'six',
  'sept',
  'huit',
  'neuf',
  'dix'
]

const fractionNames = [
  '',
  'dixième',
  'centième',
  'millième',
  'dix-millième',
  'cent-millième',
  'millionième',
  'dix-millionième',
  'cent-millionième',
  'milliardième',
  'dix-milliardième',
  'cent-milliardième'
]

function de1a99EnMot (nb, { ordinal } = {}) {
  const dizaines = Math.trunc(nb / 10)
  let unites = nb % 10
  const liaison = (unites === 1 && ![0, 1, 8, 9].includes(dizaines)) ? 'et' : ''
  if ([1, 7, 9].includes(dizaines)) unites += 10
  let mot = [
    dizaineNames[dizaines],
    liaison,
    uniteNames20[unites]
  ].filter(s => s).join('-')
  if (!ordinal && /.+(vingt|cent)$/.test(mot)) mot += 's'
  return mot
}

function de1a1000EnMot (nb, { ordinal } = {}) {
  if (nb < 100) return de1a99EnMot(nb, { ordinal })
  if (nb === 100) return 'cent'
  if (nb === 1000) return 'mille'
  // y’a au moins une centaine et qq
  const centaines = Math.trunc(nb / 100)
  const reste = nb % 100
  let resultat = uniteNames[centaines]
  if (resultat) resultat += '-'
  resultat += 'cent'
  if (reste) {
    resultat += '-' + de1a99EnMot(reste, { ordinal })
  } else if (!ordinal && centaines > 1) {
    resultat += 's'
  }
  return resultat
}

/**
 * Retourne nb écrit en un mot
 * @param {number} nb Entier compris entre 0 et 999 999 999 999
 * @param {Object} [options]
 * @param {boolean} [options.ordinal] passer true pour un ordinal (vingt ou cent reste invariable s’il termine le mot, aurait eu un 's' sinon)
 * @return {string}
 * @throws {TypeError} si nb n’est pas un entier
 * @throws {RangeError} si nb n’est pas entre 0 et 999 999 999 999
 */
export function entierEnMot (nb, { ordinal } = {}) {
  if (!Number.isInteger(nb)) throw TypeError('entierEnMot ne peut convertir que des entiers')
  if (nb < 0 || nb >= 1e13) throw RangeError(`entierEnMot ne peut convertir que des entiers entre 0 et 999 999 999 999 (${nb} fourni)`)
  if (nb === 0) return 'zéro'
  if (nb < 100) return de1a99EnMot(nb, { ordinal })
  if (nb < 1000) return de1a1000EnMot(nb, { ordinal })

  // nb en string de 12 caractères
  const snb = String(nb).padStart(12, '0')
  // on découpe en 4 morceaux, on veut des numbers
  const re = /\d{3}/y // y pour sticky, le lastIndex de la regex avance à chaque exec, cf https://developer.mozilla.org/fr/docs/Web/JavaScript/Reference/Global_Objects/RegExp/sticky
  const chunks = []
  while (re.lastIndex < 12) chunks.push(Number(re.exec(snb)))
  const [milliards, millions, milliers, unites] = chunks
  const pluriel = n => n > 1 ? 's' : ''
  const portions = []
  if (milliards) {
    portions.push(de1a1000EnMot(milliards))
    portions.push('milliard' + pluriel(milliards))
  }
  if (millions) {
    portions.push(de1a1000EnMot(millions))
    portions.push('million' + pluriel(millions))
  }
  if (milliers) {
    if (milliers > 1) portions.push(de1a1000EnMot(milliers, { ordinal: true }))
    portions.push('mille')
  }
  if (unites) portions.push(de1a1000EnMot(unites, { ordinal }))
  return portions.join('-')
}

/**
 * Retourne les mots correspondant au nombre (orthographe recommandée depuis 1990)
 * @param {number} nb Un nombre entre -1e13 et 1e13 (exclus)
 * @param {Object} [options]
 * @param {boolean} [options.ajouterUnites=false] Passer true pour que le mot "unités" soit ajouté avant la partie décimale (3,14 => trois unités et quatorze centièmes)
 * @param {number} [options.maxDecimales=11] Préciser un nb max de décimales (nb sera alors arrondi), entre 0 et 11
 * @param [options.garderZerosNonSignificatifs] Passer true pour conserver les zéros non significatifs (avec maxDecimales à 3 le nb 3,14 renverra "trois et cent-quarante millièmes" plutôt que "trois et quatorze centièmes")
 * @return {string}
 */
export function nombreEnMots (nb, { ajouterUnites, maxDecimales = 11, garderZerosNonSignificatifs } = {}) {
  if (!Number.isFinite(nb)) throw TypeError('nombre invalide')
  if (Math.abs(nb) >= 1e13) throw RangeError('nombreEnMots ne peut convertir que des nombres inférieurs à 10^13')
  if (!Number.isInteger(maxDecimales) || maxDecimales > 11 || maxDecimales < 0) {
    console.error(Error(`maxDecimales invalide (${maxDecimales}) => ramené à 11`))
    maxDecimales = 11
  }
  if (maxDecimales === 0) return entierEnMot(Math.round(nb))
  let prefix = ''
  if (nb < 0) {
    prefix = 'moins '
    nb = Math.abs(nb)
  }
  // nb est positif
  let unite = ''
  if (ajouterUnites) {
    unite = ' unité'
    if (nb >= 2) unite += 's'
  }
  let strDecim
  if (Number.isInteger(nb)) {
    if (maxDecimales && garderZerosNonSignificatifs) {
      // y’a pas de décimales mais on en veut
      strDecim = '0'.repeat(maxDecimales)
    } else {
      if (nb === 0 && ajouterUnites) return 'zéro unité'
      if (nb === 1 && ajouterUnites) return prefix + 'une unité'
      return prefix + entierEnMot(nb) + unite
    }
  }
  // y’a des décimales, partie entière
  let partieEntiere
  if (nb < 1) {
    partieEntiere = unite ? 'zéro' : ''
  } else if (nb < 2) {
    partieEntiere = unite ? 'une' : 'un'
  } else {
    partieEntiere = entierEnMot(Math.trunc(nb))
  }
  const liaison = partieEntiere ? ' et ' : ''
  // attention, il ne faut pas appliquer .toFixed(maxDecimales) au nombre d’origine
  // car si le résultat dépasse 16 caractères (en 64 bits) ça donne n’importe quoi
  // On ne peut pas non plus caster en string, avec String() ou .toString(), car ça peut donner du 1e-7 par ex
  // Faut utiliser toFixed sur la partie décimale seulement,
  // sauf que nb - Math.trunc(nb) est pas fiable non plus (avec 298765432191.1001 ça donne 0.10009765625)
  if (!strDecim) {
    strDecim = nb < 1
      ? nb.toFixed(maxDecimales).substr(2)
      : String(nb).replace(/\d+\./, '').padEnd(maxDecimales, '0')
    // dans le 2e cas ça peut être trop…
    if (strDecim.length > maxDecimales) {
      const diviseur = Math.pow(10, strDecim.length - maxDecimales)
      strDecim = String(Math.round(Number(strDecim) / diviseur))
    }
  }
  if (!garderZerosNonSignificatifs) strDecim = strDecim.replace(/0+$/, '')
  const nbDecim = Number(strDecim)
  const partieDecimale = entierEnMot(nbDecim)
  const uniteDecimale = fractionNames[strDecim.length]
  return prefix + partieEntiere + unite + liaison + partieDecimale + ' ' + uniteDecimale + (nbDecim > 1 ? 's' : '')
} // nombreEnMot