// © 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'); }