gestion_table_tournante_libre/src/csv/lecture.js

166 lines
6.8 KiB
JavaScript
Raw Normal View History

[ADD] csv: decode in four steps, choose the separator, cut records Bytes decode in the order of § 10.1: UTF-8 mark, UTF-16 mark, a NUL in the first 4 KiB without a mark refused with its remedy, then strict UTF-8, then windows-1252. A mark its bytes contradict, and any decoded NUL, are refused: decoded, they would give names no later check sees. The separator is the candidate among ";", "," and tab that leaves no quote open and gives one field count above one on the first twenty records; a lone recognised header reads as one column. Records follow RFC 4180 and are numbered as a spreadsheet numbers its rows. Checked: 89 tests, 60 mutants killed; review found no behaviour defect. --- FR --- [ADD] csv : décodage en quatre temps, choix du séparateur, découpage Les octets se décodent dans l'ordre du § 10.1 : marque UTF-8, marque UTF-16, octet nul dans les 4 Kio sans marque refusé avec son remède, UTF-8 strict, puis windows-1252. Une marque contredite par ses octets, et tout nul décodé, sont refusés : décodés, ils donneraient des noms qu'aucun contrôle ne verrait. Le séparateur parmi « ; », « , » et la tabulation ne laisse aucun guillemet ouvert et donne un même nombre de champs, plus d'un, sur vingt enregistrements ; un en-tête seul reconnu se lit en une colonne. Découpage RFC 4180, rangs comme dans un tableur. Vérifié : 89 épreuves, 60 mutants tués ; aucun défaut à la revue. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 07:05:05 -04:00
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Lecture d'un texte CSV déjà décodé (§ 10.1, § 10.2) : sa découpe en
// enregistrements et le choix de son séparateur. L'import d'un fichier et le
// collage en bloc passent par ces mêmes fonctions : le texte qu'elles reçoivent
// n'a plus d'origine, et un second analyseur « simplifié » divergerait du
// premier sur les accents et les guillemets.
import { ErreurCsv } from './erreurs.js';
const GUILLEMET = '"';
// Candidats du séparateur, dans l'ordre qui départage deux candidats à égalité
// de tout le reste.
const CANDIDATS = [';', ',', '\t'];
// Nombre d'enregistrements que le choix du séparateur lit.
const FENETRE = 20;
// Parcourt texte en un seul passage et rend ses enregistrements, avec l'état
// des guillemets à la fin de la lecture. La lecture s'arrête après limite
// enregistrements terminés ; guillemetOuvert est alors faux, la lecture
// s'arrêtant hors d'un champ cité.
//
// La découpe suit RFC 4180, avec ces précisions :
// - un guillemet ouvre un champ cité quand il en est le premier caractère ;
// ailleurs, c'est un caractère comme un autre ;
// - dans un champ cité, le séparateur et les fins de ligne font partie du
// texte, deux guillemets font un guillemet, un seul referme le champ ; ce qui
// suit le guillemet fermant, jusqu'au séparateur, reste dans le champ ;
// - CRLF, LF ou CR terminent un enregistrement, CRLF comptant pour une seule
// fin ;
// - une ligne vide est un enregistrement d'un seul champ vide ; la ligne vide
// qui suit la dernière fin de ligne ne fait pas d'enregistrement ;
// - un séparateur null ne sépare rien : chaque enregistrement est un champ ;
// - aucun blanc n'est retiré.
function analyser(texte, separateur, limite) {
const enregistrements = [];
let enregistrement = [];
let champ = '';
let cite = false;
let debutDeChamp = true;
let debutDeLigne = true;
for (let i = 0; i < texte.length; i += 1) {
const c = texte[i];
if (cite) {
if (c !== GUILLEMET) {
champ += c;
} else if (texte[i + 1] === GUILLEMET) {
champ += GUILLEMET;
i += 1;
} else {
cite = false;
}
continue;
}
if (c === '\n' || c === '\r') {
if (c === '\r' && texte[i + 1] === '\n') i += 1;
enregistrement.push(champ);
enregistrements.push(enregistrement);
if (enregistrements.length >= limite) return { enregistrements, guillemetOuvert: false };
enregistrement = [];
champ = '';
debutDeChamp = true;
debutDeLigne = true;
continue;
}
debutDeLigne = false;
if (c === separateur) {
enregistrement.push(champ);
champ = '';
debutDeChamp = true;
} else if (c === GUILLEMET && debutDeChamp) {
cite = true;
} else {
champ += c;
debutDeChamp = false;
}
}
if (!debutDeLigne) {
enregistrement.push(champ);
enregistrements.push(enregistrement);
}
return { enregistrements, guillemetOuvert: cite };
}
/**
* Découpe un texte en enregistrements de champs, selon RFC 4180 : champs cités,
* guillemets doublés, séparateur et fins de ligne dans un champ cité, fins de
* ligne CRLF, LF ou CR. Une ligne vide est un enregistrement d'un seul champ
* vide ; la ligne vide qui suit la dernière fin de ligne ne fait pas
* d'enregistrement. Aucun blanc n'est retiré des champs.
*
* Un enregistrement se désigne par son rang à partir de 1, l'en-tête compris :
* le numéro de ligne qu'un tableur lui donne. Un champ cité sur plusieurs
* lignes ne fait qu'un enregistrement ; la découpe ne rend aucun numéro de
* ligne du texte.
*
* @param {string} texte
* @param {string|null} separateur un caractère, ou null pour un fichier à une
* colonne, dont chaque ligne est un seul champ
* @returns {{ enregistrements: string[][], guillemetOuvert: boolean }}
* guillemetOuvert est vrai quand le texte s'achève dans un champ cité : le
* dernier enregistrement porte alors le texte lu jusque-là.
*/
export function decouper(texte, separateur) {
return analyser(texte, separateur, Infinity);
}
/**
* Vrai quand chaque champ de l'enregistrement est vide ou réduit à des blancs :
* une ligne vide, une ligne de séparateurs seuls, une ligne de blancs. Le choix
* du séparateur ne compte pas ces enregistrements.
*
* @param {string[]} enregistrement
* @returns {boolean}
*/
export function enregistrementVide(enregistrement) {
return enregistrement.every((champ) => champ.trim() === '');
}
/**
* Choisit le séparateur parmi « ; », « , » et la tabulation en lisant les vingt
* premiers enregistrements avec chacun. Un candidat convient quand la lecture
* s'achève sans guillemet ouvert et que les enregistrements non vides ont tous
* le même nombre de champs, supérieur à 1 : sur un fichier séparé par des
* virgules, « ; » donne lui aussi un nombre de champs constant, un seul, et la
* constance ne suffit pas.
*
* À égalité, le candidat dont le premier enregistrement non vide compte le plus
* d'en-têtes reconnus l'emporte ; à égalité encore, le premier dans l'ordre
* « ; », « , », tabulation.
*
* Quand aucun candidat ne convient et que la première ligne entière, ses blancs
* de bord retirés, est un en-tête reconnu, le fichier n'a qu'une colonne :
* separateur est null, et un nom qui porte une virgule reste entier. Sinon,
* ErreurCsv('SEPARATEUR_INTROUVABLE').
*
* @param {string} texte
* @param {(entete: string) => unknown} reconnaitre rend une valeur vraie pour
* un en-tête connu ; reçoit chaque champ du premier enregistrement non vide
* tel qu'il est écrit, ou la première ligne entière d'un fichier à une
* colonne, ses blancs de bord retirés
* @returns {{ separateur: string|null }}
* @throws {ErreurCsv} SEPARATEUR_INTROUVABLE
*/
export function choisirSeparateur(texte, reconnaitre) {
let retenu = null;
for (const candidat of CANDIDATS) {
const { enregistrements, guillemetOuvert } = analyser(texte, candidat, FENETRE);
if (guillemetOuvert) continue;
const pleins = enregistrements.filter((e) => !enregistrementVide(e));
if (pleins.length === 0) continue;
const largeur = pleins[0].length;
if (largeur < 2 || pleins.some((e) => e.length !== largeur)) continue;
const reconnus = pleins[0].filter((entete) => reconnaitre(entete)).length;
if (retenu === null || reconnus > retenu.reconnus) retenu = { separateur: candidat, reconnus };
}
if (retenu !== null) return { separateur: retenu.separateur };
const premiere = analyser(texte, null, FENETRE).enregistrements.find((e) => !enregistrementVide(e));
if (premiere !== undefined && reconnaitre(premiere[0].trim())) return { separateur: null };
throw new ErreurCsv('SEPARATEUR_INTROUVABLE');
}