Columns associate by header, never by position, insensitive to case, accents and spaces, with the § 10.1 synonyms; two columns for one field associate neither and ask. The preview imports nothing: it lists valid rows, refused rows with their reason, each group of merged spellings with its counts, duplicates never merged, and global refusals, an open quote or a NUL included. A paste goes through the same parser, and the separator rule lives once, in lecture.js, whose refusal names its line. Checked: 185 CSV tests, eight spreadsheet-shaped fixtures; two reviews. --- FR --- [ADD] csv : colonnes par en-tête, valeurs d'exclu fermées, aperçu Les colonnes s'associent par en-tête, jamais par position, sans égard à la casse, aux accents ni aux espaces, avec les synonymes du § 10.1 ; deux colonnes d'un même champ ne s'associent pas, et le logiciel demande. L'aperçu n'importe rien : lignes valides, lignes refusées et leur motif, chaque groupe d'orthographes fondues et ses comptes, doublons jamais fondus, refus globaux, guillemet ouvert et nul compris. Le collage passe par le même analyseur, et la règle du séparateur ne s'écrit qu'une fois, dans lecture.js, dont le refus nomme sa ligne. Vérifié : 185 épreuves CSV, huit données de tableur ; deux revues. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
197 lines
8.8 KiB
JavaScript
197 lines
8.8 KiB
JavaScript
// © 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() === '');
|
|
}
|
|
|
|
// Lecture d'un texte par un candidat dans la fenêtre du choix. L'en-tête est
|
|
// le premier enregistrement non vide parmi ceux que la fenêtre termine : un
|
|
// guillemet resté ouvert court jusqu'à la fin du texte, et l'enregistrement
|
|
// où il s'ouvre, le dernier lu, reste inachevé. Rend null quand l'en-tête
|
|
// manque ou n'a qu'un champ : la constance seule ne suffit pas, et le candidat
|
|
// n'a pas de défaut à nommer. Sinon, rend le nombre d'en-têtes reconnus et le
|
|
// premier défaut qui écarte le candidat, ou null : un enregistrement non vide
|
|
// dont le nombre de champs diffère de celui de l'en-tête, à son rang ; à
|
|
// défaut, le guillemet resté ouvert, au rang où il s'ouvre.
|
|
function lireAvec(texte, candidat, reconnaitre) {
|
|
const { enregistrements, guillemetOuvert } = analyser(texte, candidat, FENETRE);
|
|
const termines = guillemetOuvert ? enregistrements.slice(0, -1) : enregistrements;
|
|
const entete = termines.find((e) => !enregistrementVide(e));
|
|
if (entete === undefined || entete.length < 2) return null;
|
|
const ecart = termines.findIndex((e) => !enregistrementVide(e) && e.length !== entete.length);
|
|
let defaut = null;
|
|
if (ecart !== -1) defaut = { ligne: ecart + 1, cause: 'NOMBRE_DE_CHAMPS' };
|
|
else if (guillemetOuvert) defaut = { ligne: enregistrements.length, cause: 'GUILLEMET_OUVERT' };
|
|
return { separateur: candidat, reconnus: entete.filter((champ) => reconnaitre(champ)).length, defaut };
|
|
}
|
|
|
|
// La lecture qui l'emporte, de la meilleure jusqu'ici et d'une suivante dans
|
|
// l'ordre des candidats : celle qui reconnaît le plus d'en-têtes, la première
|
|
// à égalité.
|
|
const meilleure = (jusquIci, suivante) =>
|
|
jusquIci === null || suivante.reconnus > jusquIci.reconnus ? suivante : jusquIci;
|
|
|
|
/**
|
|
* 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'), dont les détails nomment ce qui écarte
|
|
* le candidat que le même départage désigne parmi ceux dont l'en-tête a au
|
|
* moins deux champs : { separateur, ligne, cause }, ligne étant le rang de
|
|
* l'enregistrement fautif et cause NOMBRE_DE_CHAMPS — le premier
|
|
* enregistrement non vide dont le nombre de champs diffère de celui de
|
|
* l'en-tête — ou GUILLEMET_OUVERT — sans écart, le guillemet resté ouvert,
|
|
* au rang où il s'ouvre. Sans un tel candidat, les détails sont vides.
|
|
*
|
|
* @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, détails
|
|
* { separateur, ligne, cause } ou {}
|
|
*/
|
|
export function choisirSeparateur(texte, reconnaitre) {
|
|
let retenu = null;
|
|
let ecarte = null;
|
|
for (const candidat of CANDIDATS) {
|
|
const lecture = lireAvec(texte, candidat, reconnaitre);
|
|
if (lecture === null) continue;
|
|
if (lecture.defaut === null) retenu = meilleure(retenu, lecture);
|
|
else ecarte = meilleure(ecarte, lecture);
|
|
}
|
|
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 };
|
|
if (ecarte === null) throw new ErreurCsv('SEPARATEUR_INTROUVABLE');
|
|
throw new ErreurCsv('SEPARATEUR_INTROUVABLE', { separateur: ecarte.separateur, ...ecarte.defaut });
|
|
}
|