150 lines
6.6 KiB
JavaScript
150 lines
6.6 KiB
JavaScript
|
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
|
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||
|
|
|
||
|
|
// La mise en forme des nombres, des durées, des dates et des horodatages
|
||
|
|
// (§ 5.4, § 5.10, § 11.7, § 18.4). Aucune fonction ne lit la locale du
|
||
|
|
// système : les chiffres se groupent par trois, séparés d'une espace fine
|
||
|
|
// insécable (U+202F), la décimale suit une virgule, un négatif porte le signe
|
||
|
|
// moins (U+2212). Tout texte rendu qui porte une lettre — unité, mois, ordre
|
||
|
|
// de grandeur — vient d'une clé format.… par t, en un seul appel : en langue
|
||
|
|
// témoin, il est encadré de la marque une fois. Un nombre, qui ne porte
|
||
|
|
// aucune lettre, n'y passe pas.
|
||
|
|
|
||
|
|
const ESPACE_FINE = '\u{202F}';
|
||
|
|
const MOINS = '\u{2212}';
|
||
|
|
|
||
|
|
// L'échelle des ordres de grandeur d'une durée (§ 5.10) : chaque pas en
|
||
|
|
// secondes et sa clé. Sous le premier seuil, moins d'une minute ; dès le
|
||
|
|
// dernier, plusieurs heures ; entre les deux, le pas le plus proche en
|
||
|
|
// rapport : la frontière de deux pas voisins a et b est √(a·b), comparée
|
||
|
|
// exactement, s² contre a·b.
|
||
|
|
const MOINS_D_UNE_MINUTE = 60;
|
||
|
|
const PLUSIEURS_HEURES = 5400;
|
||
|
|
const PAS = [
|
||
|
|
[120, 'format.duree.deuxMinutes'],
|
||
|
|
[300, 'format.duree.cinqMinutes'],
|
||
|
|
[600, 'format.duree.dixMinutes'],
|
||
|
|
[1200, 'format.duree.vingtMinutes'],
|
||
|
|
[1800, 'format.duree.demiHeure'],
|
||
|
|
[3600, 'format.duree.uneHeure'],
|
||
|
|
];
|
||
|
|
|
||
|
|
const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||
|
|
const HORODATAGE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})[+-](\d{2}):(\d{2})$/;
|
||
|
|
|
||
|
|
// Le nombre de jours d'un mois du calendrier grégorien.
|
||
|
|
function joursDuMois(annee, mois) {
|
||
|
|
if (mois === 2) return annee % 4 === 0 && (annee % 100 !== 0 || annee % 400 === 0) ? 29 : 28;
|
||
|
|
return [4, 6, 9, 11].includes(mois) ? 30 : 31;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Année, mois et jour d'une date AAAA-MM-JJ du calendrier, ou RangeError.
|
||
|
|
function lireDate(annee, mois, jour, texte) {
|
||
|
|
const lue = { annee: Number(annee), mois: Number(mois), jour: Number(jour) };
|
||
|
|
if (lue.mois < 1 || lue.mois > 12 || lue.jour < 1 || lue.jour > joursDuMois(lue.annee, lue.mois)) {
|
||
|
|
throw new RangeError(`format : ${JSON.stringify(texte)} n'est pas un jour du calendrier`);
|
||
|
|
}
|
||
|
|
return lue;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Un nombre fini, ou l'erreur qui dit pourquoi il ne se met pas en forme.
|
||
|
|
function exigerFini(valeur, quoi) {
|
||
|
|
if (typeof valeur !== 'number') throw new TypeError(`format : ${quoi} doit être un nombre, reçu ${String(valeur)}`);
|
||
|
|
if (!Number.isFinite(valeur)) throw new RangeError(`format : ${quoi} non fini, ${valeur}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Les fonctions de mise en forme, dont chaque texte vient de t.
|
||
|
|
*
|
||
|
|
* @param {(cle: string, details?: Object) => string} t la traduction
|
||
|
|
* @returns {{
|
||
|
|
* nombre: (valeur: number|null, options?: {decimales?: number}) => string,
|
||
|
|
* duree: (ms: number) => string,
|
||
|
|
* ordreDeGrandeur: (secondes: number) => string,
|
||
|
|
* date: (date: string) => string,
|
||
|
|
* horodatage: (horodatage: string) => string,
|
||
|
|
* }}
|
||
|
|
*/
|
||
|
|
export function creerFormat(t) {
|
||
|
|
// Les chiffres d'un nombre fini, arrondi à decimales chiffres après la
|
||
|
|
// virgule, sans passer par la traduction.
|
||
|
|
function chiffres(valeur, decimales) {
|
||
|
|
if (!Number.isInteger(decimales) || decimales < 0 || decimales > 20) {
|
||
|
|
throw new RangeError(`format : décimales hors de 0 à 20, ${decimales}`);
|
||
|
|
}
|
||
|
|
const ecrit = Math.abs(valeur).toFixed(decimales);
|
||
|
|
if (ecrit.includes('e')) throw new RangeError(`format : ${valeur} dépasse ce qui s'écrit en chiffres`);
|
||
|
|
const [entiere, fraction] = ecrit.split('.');
|
||
|
|
const groupee = entiere.replace(/\B(?=(\d{3})+$)/g, ESPACE_FINE);
|
||
|
|
const nul = /^[0.]*$/.test(ecrit);
|
||
|
|
const signe = valeur < 0 && !nul ? MOINS : '';
|
||
|
|
return `${signe}${groupee}${fraction === undefined ? '' : `,${fraction}`}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Un nombre ; null, une valeur absente, s'écrit format.vide, jamais 0. */
|
||
|
|
function nombre(valeur, { decimales = 0 } = {}) {
|
||
|
|
if (valeur === null) return t('format.vide');
|
||
|
|
exigerFini(valeur, 'nombre');
|
||
|
|
return chiffres(valeur, decimales);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Une durée en millisecondes : sous la minute, au dixième de seconde, un
|
||
|
|
* compte rond sans décimale ; sous l'heure, minutes et secondes entières ;
|
||
|
|
* au-delà, heures et minutes entières. Un arrondi qui atteint l'unité
|
||
|
|
* suivante s'écrit dans elle.
|
||
|
|
*/
|
||
|
|
function duree(ms) {
|
||
|
|
exigerFini(ms, 'durée');
|
||
|
|
if (ms < 0) throw new RangeError(`format : durée négative, ${ms}`);
|
||
|
|
const dixiemes = Math.round(ms / 100);
|
||
|
|
if (dixiemes < 600) {
|
||
|
|
const secondes = dixiemes % 10 === 0 ? chiffres(dixiemes / 10, 0) : chiffres(dixiemes / 10, 1);
|
||
|
|
return t('format.duree.secondes', { secondes });
|
||
|
|
}
|
||
|
|
const secondes = Math.round(ms / 1000);
|
||
|
|
if (secondes < 3600) {
|
||
|
|
return t('format.duree.minutes', { minutes: Math.floor(secondes / 60), secondes: secondes % 60 });
|
||
|
|
}
|
||
|
|
const minutes = Math.round(ms / 60_000);
|
||
|
|
return t('format.duree.heures', { heures: chiffres(Math.floor(minutes / 60), 0), minutes: minutes % 60 });
|
||
|
|
}
|
||
|
|
|
||
|
|
/** L'ordre de grandeur d'une durée en secondes (§ 5.10). */
|
||
|
|
function ordreDeGrandeur(secondes) {
|
||
|
|
exigerFini(secondes, 'durée');
|
||
|
|
if (secondes < 0) throw new RangeError(`format : durée négative, ${secondes}`);
|
||
|
|
if (secondes < MOINS_D_UNE_MINUTE) return t('format.duree.moinsDUneMinute');
|
||
|
|
if (secondes >= PLUSIEURS_HEURES) return t('format.duree.plusieursHeures');
|
||
|
|
const carre = secondes * secondes;
|
||
|
|
let rang = 0;
|
||
|
|
while (rang + 1 < PAS.length && carre >= PAS[rang][0] * PAS[rang + 1][0]) rang += 1;
|
||
|
|
return t(PAS[rang][1]);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Une date AAAA-MM-JJ en lettres, « 1er » pour le premier du mois. */
|
||
|
|
function date(texte) {
|
||
|
|
const morceaux = DATE.exec(String(texte));
|
||
|
|
if (morceaux === null) throw new RangeError(`format : date AAAA-MM-JJ attendue, reçu ${JSON.stringify(texte)}`);
|
||
|
|
return t('format.date', lireDate(...morceaux.slice(1), texte));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Un horodatage AAAA-MM-JJTHH:MM:SS±HH:MM en lettres, à l'heure locale
|
||
|
|
* qu'il porte : la date, puis l'heure et la minute.
|
||
|
|
*/
|
||
|
|
function horodatage(texte) {
|
||
|
|
const morceaux = HORODATAGE.exec(String(texte));
|
||
|
|
if (morceaux === null) {
|
||
|
|
throw new RangeError(`format : horodatage AAAA-MM-JJTHH:MM:SS±HH:MM attendu, reçu ${JSON.stringify(texte)}`);
|
||
|
|
}
|
||
|
|
const [heure, minute, seconde, decalageH, decalageM] = morceaux.slice(4).map(Number);
|
||
|
|
if (heure > 23 || minute > 59 || seconde > 59 || decalageH > 23 || decalageM > 59) {
|
||
|
|
throw new RangeError(`format : ${JSON.stringify(texte)} n'est pas une heure du jour`);
|
||
|
|
}
|
||
|
|
return t('format.horodatage', { ...lireDate(...morceaux.slice(1, 4), texte), heure, minute });
|
||
|
|
}
|
||
|
|
|
||
|
|
return { nombre, duree, ordreDeGrandeur, date, horodatage };
|
||
|
|
}
|