[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
This commit is contained in:
Mathieu Benoit 2026-10-06 07:05:05 -04:00
parent b9090508ff
commit dd088f2dd9
5 changed files with 1169 additions and 0 deletions

130
src/csv/encodage.js Normal file
View file

@ -0,0 +1,130 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Décodage des octets d'un fichier CSV en texte (§ 10.1). Une détection qui se
// trompe ne fait échouer aucun calcul : un nom décodé de travers se découvre
// sur le plan imprimé. La détection suit donc quatre temps dans un ordre fixe,
// et n'emploie que les décodeurs que nomme le § 10.1. Un fichier que ces temps
// ne lisent pas sûrement est refusé, et le refus nomme le remède.
import { ErreurCsv } from './erreurs.js';
// Quatre kibioctets : la part du fichier où se cherche un octet nul.
const OCTETS_SONDES = 4096;
const MARQUE_UTF8 = [0xef, 0xbb, 0xbf];
const MARQUE_UTF16LE = [0xff, 0xfe];
const MARQUE_UTF16BE = [0xfe, 0xff];
// Vrai quand octets s'ouvre sur marque ; un fichier plus court qu'elle ne la
// porte pas.
const commencePar = (octets, marque) => marque.every((octet, i) => octets[i] === octet);
// Refus d'encodage de code donné. Chaque refus a ses propres détails, et tous
// renvoient au même remède : un fichier réenregistré en UTF-8.
const refuser = (code) => new ErreurCsv(code, { remede: 'ENREGISTRER_EN_UTF8' });
/**
* Vrai quand le texte porte le caractère nul (U+0000), qu'aucun CSV ne porte.
* decoder refuse le texte qu'il décode quand il en porte un ; la même fonction
* contrôle un texte qui n'est pas passé par decoder.
*
* @param {string} texte
* @returns {boolean}
*/
export function contientNul(texte) {
return texte.includes('\0');
}
// Octets de l'entrée : un Uint8Array, dont un Buffer, tel quel ; un ArrayBuffer
// par une vue sur lui. Les deux se reconnaissent à leur étiquette de type, non
// par instanceof, que fait échouer un tampon venu d'un autre contexte
// d'exécution. Tout autre argument est refusé : new Uint8Array en ferait un
// fichier vide, d'une promesse non attendue comme d'un Blob.
function enOctets(entree) {
const genre = Object.prototype.toString.call(entree);
if (genre === '[object Uint8Array]') return entree;
if (genre === '[object ArrayBuffer]') return new Uint8Array(entree);
throw new TypeError('octets : Uint8Array ou ArrayBuffer attendu');
}
// Texte UTF-16 des octets, la marque déjà retirée ; encodage est 'utf-16le' ou
// 'utf-16be', l'étiquette même du décodeur. Le décodage est strict : un nombre
// impair d'octets ou un substitut isolé lève UTF16_INVALIDE, là où le décodeur
// sèmerait des caractères de remplacement dans les noms.
function lireUtf16(octets, encodage) {
try {
return { texte: new TextDecoder(encodage, { fatal: true }).decode(octets), encodage };
} catch {
throw refuser('UTF16_INVALIDE');
}
}
// Texte UTF-8 des octets, ou null quand ils ne sont pas de l'UTF-8 valide.
function lireUtf8(octets) {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(octets);
} catch {
return null;
}
}
// Les quatre temps de decoder, sans le contrôle du caractère nul.
function lireOctets(octets) {
if (commencePar(octets, MARQUE_UTF8)) {
const texte = lireUtf8(octets.subarray(MARQUE_UTF8.length));
if (texte === null) throw refuser('UTF8_INVALIDE');
return { texte, encodage: 'utf-8-bom' };
}
if (commencePar(octets, MARQUE_UTF16LE)) {
return lireUtf16(octets.subarray(MARQUE_UTF16LE.length), 'utf-16le');
}
if (commencePar(octets, MARQUE_UTF16BE)) {
return lireUtf16(octets.subarray(MARQUE_UTF16BE.length), 'utf-16be');
}
if (octets.subarray(0, OCTETS_SONDES).includes(0)) throw refuser('UTF16_SANS_MARQUE');
const texte = lireUtf8(octets);
if (texte !== null) return { texte, encodage: 'utf-8' };
// windows-1252 donne un caractère à chaque octet : le repli n'échoue jamais.
return { texte: new TextDecoder('windows-1252').decode(octets), encodage: 'windows-1252' };
}
/**
* Décode les octets d'un fichier CSV et rend le texte avec l'encodage retenu,
* en quatre temps, dans cet ordre :
* 1. la marque d'ordre d'octets UTF-8, qui déclare l'encodage : des octets
* qui ne sont pas de l'UTF-8 valide sont refusés ;
* 2. la marque UTF-16, LE puis BE, qui déclare l'encodage : des octets qui ne
* sont pas de l'UTF-16 valide, nombre impair d'octets ou substitut isolé,
* sont refusés ;
* 3. sans marque, un octet nul parmi les 4 096 premiers octets : refus ;
* 4. UTF-8 strict, sinon windows-1252.
* La marque n'entre pas dans le texte rendu. Quel que soit le temps qui a
* décodé, un texte qui porte le caractère nul est refusé.
*
* Le troisième temps précède le quatrième parce qu'un texte UTF-16 sans marque
* se lit sans erreur dans l'un ou l'autre des décodeurs suivants : en UTF-8
* quand il n'a pas d'accent, chaque octet nul s'y lisant comme le caractère
* nul, en windows-1252 quand il en a, ce décodeur acceptant tout octet. Le
* texte rendu serait entrelardé de caractères nuls, sans qu'aucun calcul
* n'échoue. Le refus du caractère nul rattrape ce que ce temps ne voit pas :
* un nul au-delà des 4 096 premiers octets, et un texte que sa marque fait lire
* sans erreur, comme l'UTF-32 LE, dont la marque FF FE 00 00 commence par celle
* de l'UTF-16 LE.
*
* Sous la marque UTF-8, un repli sur windows-1252 changerait chaque accent de la
* partie valide en caractères parasites, sans qu'aucun calcul n'échoue : le
* fichier est refusé.
*
* @param {Uint8Array|ArrayBuffer} octets l'un ou l'autre, de n'importe quel
* contexte d'exécution
* @returns {{ texte: string, encodage: 'utf-8-bom'|'utf-16le'|'utf-16be'|'utf-8'|'windows-1252' }}
* @throws {ErreurCsv} UTF16_SANS_MARQUE, UTF16_INVALIDE, UTF8_INVALIDE ou
* CARACTERE_NUL, chacun de détails { remede: 'ENREGISTRER_EN_UTF8' }
* @throws {TypeError} quand octets n'est ni un Uint8Array ni un ArrayBuffer
*/
export function decoder(octets) {
const lu = lireOctets(enOctets(octets));
if (contientNul(lu.texte)) throw refuser('CARACTERE_NUL');
return lu;
}

353
src/csv/encodage.test.js Normal file
View file

@ -0,0 +1,353 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du décodage d'un fichier CSV (§ 10.1, § 14.10) : les cinq
// encodages que rend decoder, les quatre temps dans leur ordre, les quatre
// refus et leur remède — UTF-16 sans marque, UTF-16 invalide sous sa marque,
// UTF-8 invalide sous la marque UTF-8, caractère nul dans le texte décodé —,
// la frontière des quatre premiers kibioctets, la forme de l'entrée, y
// compris un tampon venu d'un autre contexte d'exécution. Les octets se
// construisent ici sans passer par un décodeur : UTF-8 par TextEncoder,
// UTF-16 et UTF-32 unité par unité, windows-1252 octet par octet.
import assert from 'node:assert/strict';
import { runInNewContext } from 'node:vm';
import { describe, test } from '../../test/lanceur.js';
import { contientNul, decoder } from './encodage.js';
import { ErreurCsv } from './erreurs.js';
const BENOIT = 'Benoît';
const MARQUE_UTF8 = [0xef, 0xbb, 0xbf];
const enUtf8 = (texte) => new TextEncoder().encode(texte);
// Les octets de tete, puis ceux de octets.
const avec = (tete, octets) => Uint8Array.from([...tete, ...octets]);
// Octets UTF-16 de texte, une unité à la fois : ordre vaut 'le' (octet de
// poids faible d'abord) ou 'be' ; la marque d'ordre d'octets ouvre le fichier
// quand avecMarque est vrai.
function enUtf16(texte, ordre, avecMarque = true) {
const octets = [];
if (avecMarque) octets.push(...(ordre === 'be' ? [0xfe, 0xff] : [0xff, 0xfe]));
for (let i = 0; i < texte.length; i += 1) {
const unite = texte.charCodeAt(i);
const poids = [unite >> 8, unite & 0xff];
octets.push(...(ordre === 'be' ? poids : poids.reverse()));
}
return Uint8Array.from(octets);
}
// Octets UTF-32 de texte, un point de code à la fois, précédés de la marque
// d'ordre d'octets : FF FE 00 00 pour 'le', 00 00 FE FF pour 'be'.
function enUtf32(texte, ordre) {
const octets = ordre === 'be' ? [0x00, 0x00, 0xfe, 0xff] : [0xff, 0xfe, 0x00, 0x00];
for (const caractere of texte) {
const point = caractere.codePointAt(0);
const poids = [point >>> 24, (point >> 16) & 0xff, (point >> 8) & 0xff, point & 0xff];
octets.push(...(ordre === 'be' ? poids : poids.reverse()));
}
return Uint8Array.from(octets);
}
// Octets windows-1252 de texte : un caractère Latin-1 garde son code, les deux
// signes de 0x80 à 0x9F que les épreuves emploient ont le leur ; tout autre
// caractère fait échouer l'épreuve plutôt que de s'écrire de travers.
const WINDOWS_1252_PARTICULIERS = new Map([
['€', 0x80],
['’', 0x92],
]);
const enWindows1252 = (texte) =>
Uint8Array.from(texte, (caractere) => {
const code = WINDOWS_1252_PARTICULIERS.get(caractere) ?? caractere.codePointAt(0);
assert.ok(code < 0x100, `« ${caractere} » n'existe pas en windows-1252`);
return code;
});
// L'erreur que lève fonction ; l'épreuve échoue quand elle ne lève rien.
function erreurDe(fonction) {
try {
fonction();
} catch (erreur) {
return erreur;
}
return assert.fail("rien n'a été levé");
}
// L'erreur que lève decoder sur ces octets.
const refusDe = (octets) => erreurDe(() => decoder(octets));
// Vérifie que erreur est le refus de code donné, remède compris : tout refus
// d'encodage renvoie au même remède.
function assertRefus(erreur, code) {
assert.ok(erreur instanceof ErreurCsv);
assert.equal(erreur.code, code);
assert.deepEqual(erreur.details, { remede: 'ENREGISTRER_EN_UTF8' });
}
describe('decoder : les cinq encodages (§ 10.1)', () => {
const CAS = [
['UTF-8 avec marque', avec(MARQUE_UTF8, enUtf8(BENOIT)), 'utf-8-bom'],
['UTF-8 sans marque', enUtf8(BENOIT), 'utf-8'],
['UTF-16 LE avec marque', enUtf16(BENOIT, 'le'), 'utf-16le'],
['UTF-16 BE avec marque', enUtf16(BENOIT, 'be'), 'utf-16be'],
['windows-1252', enWindows1252(BENOIT), 'windows-1252'],
];
for (const [libelle, octets, encodage] of CAS) {
test(`« Benoît » en ${libelle} : le texte, sans marque, et l'encodage retenu`, () => {
assert.deepEqual(decoder(octets), { texte: BENOIT, encodage });
});
}
test('windows-1252 : les octets 0x80 à 0x9F sont des signes, non des caractères de commande', () => {
const texte = 'l’équipe à 5 €';
assert.deepEqual(decoder(enWindows1252(texte)), { texte, encodage: 'windows-1252' });
});
test("UTF-8 : un caractère hors du plan de base et les accents d'un texte plus long", () => {
const texte = 'nom;prenom\r\nÉlodie Témoin;Noël 𝄞\r\n';
assert.deepEqual(decoder(enUtf8(texte)), { texte, encodage: 'utf-8' });
});
test("UTF-16 : une paire de substitution d'un caractère hors du plan de base, en LE comme en BE", () => {
const texte = 'Odile 𝄞';
assert.deepEqual(decoder(enUtf16(texte, 'le')), { texte, encodage: 'utf-16le' });
assert.deepEqual(decoder(enUtf16(texte, 'be')), { texte, encodage: 'utf-16be' });
});
test('un fichier vide, ou réduit à sa marque, donne un texte vide', () => {
assert.deepEqual(decoder(new Uint8Array(0)), { texte: '', encodage: 'utf-8' });
assert.deepEqual(decoder(Uint8Array.from(MARQUE_UTF8)), { texte: '', encodage: 'utf-8-bom' });
assert.deepEqual(decoder(Uint8Array.from([0xff, 0xfe])), { texte: '', encodage: 'utf-16le' });
assert.deepEqual(decoder(Uint8Array.from([0xfe, 0xff])), { texte: '', encodage: 'utf-16be' });
});
test("une marque incomplète n'en est pas une : ses octets restent du texte", () => {
assert.deepEqual(decoder(enWindows1252('ïle Fictive')), { texte: 'ïle Fictive', encodage: 'windows-1252' });
assert.deepEqual(decoder(Uint8Array.from([0xef, 0xbb])), { texte: 'ï»', encodage: 'windows-1252' });
assert.deepEqual(decoder(Uint8Array.from([0xef, 0xbb, 0x78])), { texte: 'ï»x', encodage: 'windows-1252' });
assert.deepEqual(decoder(Uint8Array.from([0xff, 0x61])), { texte: 'ÿa', encodage: 'windows-1252' });
assert.deepEqual(decoder(Uint8Array.from([0xfe, 0x61])), { texte: 'þa', encodage: 'windows-1252' });
});
test("un ArrayBuffer se décode comme les octets qu'il porte", () => {
const octets = avec(MARQUE_UTF8, enUtf8(BENOIT));
assert.deepEqual(decoder(octets.buffer), { texte: BENOIT, encodage: 'utf-8-bom' });
});
test("une vue sur une partie d'un tampon plus grand se décode seule, sans les octets voisins", () => {
const tampon = avec([0x78, 0x78], avec(MARQUE_UTF8, avec(enUtf8(BENOIT), [0x79, 0x79])));
const vue = new Uint8Array(tampon.buffer, 2, MARQUE_UTF8.length + enUtf8(BENOIT).length);
assert.deepEqual(decoder(vue), { texte: BENOIT, encodage: 'utf-8-bom' });
});
});
describe("decoder : la forme de l'entrée", () => {
// Les octets de « Benoît » en UTF-8 avec marque, écrits dans le contexte
// d'exécution neuf, qui a ses propres ArrayBuffer et Uint8Array.
const OCTETS_ECRITS = JSON.stringify([...avec(MARQUE_UTF8, enUtf8(BENOIT))]);
test("un ArrayBuffer d'un autre contexte d'exécution se décode : instanceof ne le reconnaît pas", () => {
const tampon = runInNewContext(`new Uint8Array(${OCTETS_ECRITS}).buffer`);
assert.ok(!(tampon instanceof ArrayBuffer));
assert.deepEqual(decoder(tampon), { texte: BENOIT, encodage: 'utf-8-bom' });
});
test("un Uint8Array d'un autre contexte d'exécution se décode", () => {
const vue = runInNewContext(`new Uint8Array(${OCTETS_ECRITS})`);
assert.ok(!(vue instanceof Uint8Array));
assert.deepEqual(decoder(vue), { texte: BENOIT, encodage: 'utf-8-bom' });
});
test('un Buffer de Node, sous-classe de Uint8Array, se décode', () => {
const buffer = Buffer.from(avec(MARQUE_UTF8, enUtf8(BENOIT)));
assert.deepEqual(decoder(buffer), { texte: BENOIT, encodage: 'utf-8-bom' });
});
test("une entrée qui n'est pas des octets est refusée par un TypeError, non lue comme un fichier vide", () => {
// Une promesse (arrayBuffer() non attendu), un Blob (le fichier même), une
// chaîne, rien : new Uint8Array en ferait des octets en nombre nul.
const entrees = [
'abc',
undefined,
null,
Promise.resolve(new Uint8Array(0)),
new Blob(['abc']),
[0xef, 0xbb, 0xbf],
];
for (const entree of entrees) {
assert.throws(() => decoder(entree), TypeError);
}
});
});
describe('decoder : un UTF-16 sans marque est refusé, avec son remède (§ 10.1)', () => {
test('« Benoît » en UTF-16 LE sans marque : refus, remède nommé', () => {
assertRefus(refusDe(enUtf16(BENOIT, 'le', false)), 'UTF16_SANS_MARQUE');
});
test('« Benoît » en UTF-16 BE sans marque : refus, remède nommé', () => {
assertRefus(refusDe(enUtf16(BENOIT, 'be', false)), 'UTF16_SANS_MARQUE');
});
test("un contenu sans accent est de l'UTF-8 valide en UTF-16 : refusé de même, non lu en texte entrelardé de caractères nuls", () => {
// Chaque octet nul s'y décode comme le caractère nul : l'ordre à trois
// temps rendrait « n\0o\0m\0… » sans rien refuser.
const octets = enUtf16('nom;prenom\r\nBenoit Exemple;Odile', 'le', false);
assert.doesNotThrow(() => new TextDecoder('utf-8', { fatal: true }).decode(octets));
assertRefus(refusDe(octets), 'UTF16_SANS_MARQUE');
});
test("avec un accent, le texte n'est plus de l'UTF-8 valide : l'ordre à trois temps le rendrait en windows-1252, nuls compris — refusé", () => {
const octets = enUtf16(BENOIT, 'le', false);
assert.throws(() => new TextDecoder('utf-8', { fatal: true }).decode(octets), TypeError);
assertRefus(refusDe(octets), 'UTF16_SANS_MARQUE');
});
test("l'octet nul n'est cherché sans marque que dans les 4 096 premiers octets ; au-delà, c'est le caractère nul qui est refusé", () => {
const octets = new Uint8Array(4097).fill(0x61);
octets[4095] = 0;
assertRefus(refusDe(octets), 'UTF16_SANS_MARQUE');
octets[4095] = 0x61;
octets[4096] = 0;
assertRefus(refusDe(octets), 'CARACTERE_NUL');
});
test('la sonde commence au premier octet : « A » en UTF-16 BE sans marque, un seul octet nul', () => {
assertRefus(refusDe(Uint8Array.of(0x00, 0x41)), 'UTF16_SANS_MARQUE');
});
test('un UTF-16 à marque ne se confond pas avec le refus : ses octets nuls ne le refusent pas', () => {
const octets = enUtf16('nom;prenom', 'le');
assert.ok(octets.slice(0, 4096).includes(0));
assert.deepEqual(decoder(octets), { texte: 'nom;prenom', encodage: 'utf-16le' });
});
});
describe('decoder : un UTF-16 sous sa marque doit être valide (§ 10.1)', () => {
test("un nombre impair d'octets : refus UTF16_INVALIDE, remède nommé", () => {
assertRefus(refusDe(Uint8Array.of(0xff, 0xfe, 0x61, 0x00, 0x62)), 'UTF16_INVALIDE');
assertRefus(refusDe(Uint8Array.of(0xfe, 0xff, 0x00, 0x61, 0x00)), 'UTF16_INVALIDE');
});
test('un substitut isolé, haut ou bas, en LE comme en BE : refus UTF16_INVALIDE', () => {
// Haut suivi d'une lettre, bas seul, haut en fin de fichier.
assertRefus(refusDe(Uint8Array.of(0xff, 0xfe, 0x00, 0xd8, 0x61, 0x00)), 'UTF16_INVALIDE');
assertRefus(refusDe(Uint8Array.of(0xff, 0xfe, 0x00, 0xdc)), 'UTF16_INVALIDE');
assertRefus(refusDe(Uint8Array.of(0xff, 0xfe, 0x61, 0x00, 0x00, 0xd8)), 'UTF16_INVALIDE');
assertRefus(refusDe(Uint8Array.of(0xfe, 0xff, 0xd8, 0x00, 0x00, 0x61)), 'UTF16_INVALIDE');
assertRefus(refusDe(Uint8Array.of(0xfe, 0xff, 0xdc, 0x00)), 'UTF16_INVALIDE');
});
test('une paire de substitution valide reste lue', () => {
assert.deepEqual(decoder(Uint8Array.of(0xff, 0xfe, 0x34, 0xd8, 0x1e, 0xdd)), {
texte: '𝄞',
encodage: 'utf-16le',
});
});
});
describe("decoder : la marque UTF-8 déclare l'encodage (§ 10.1)", () => {
test("une marque UTF-8 suivie d'octets windows-1252 : refus UTF8_INVALIDE, remède nommé", () => {
assertRefus(refusDe(avec(MARQUE_UTF8, enWindows1252(BENOIT))), 'UTF8_INVALIDE');
});
test("une partie valide en UTF-8 puis un octet isolé : refusée, non lue en windows-1252 où chaque accent de la partie valide deviendrait des caractères parasites", () => {
const corps = [...enUtf8('Benoît Exemple;Odile Fictive\r\n'), 0xe9];
// Sans la marque, les mêmes octets se lisent en windows-1252 : « î », en
// UTF-8 C3 AE, y devient « î », sans que rien n'échoue.
const sansMarque = decoder(Uint8Array.from(corps));
assert.equal(sansMarque.encodage, 'windows-1252');
assert.ok(sansMarque.texte.startsWith('Benoît'));
assertRefus(refusDe(avec(MARQUE_UTF8, corps)), 'UTF8_INVALIDE');
});
test('une séquence UTF-8 coupée en fin de fichier : refus UTF8_INVALIDE', () => {
assertRefus(refusDe(avec(MARQUE_UTF8, [...enUtf8('Beno'), 0xc3])), 'UTF8_INVALIDE');
});
});
describe('decoder : un caractère nul dans le texte décodé est refusé (§ 10.1)', () => {
test("un UTF-32 LE, que sa marque FF FE 00 00 fait prendre pour de l'UTF-16 LE : refus CARACTERE_NUL, non un texte entrelardé de caractères nuls", () => {
const octets = enUtf32('nom;prenom\r\nBenoît Exemple;Odile', 'le');
// Sous la marque UTF-16 LE, ces octets se lisent sans erreur, nuls compris.
assert.deepEqual([...octets.slice(0, 2)], [0xff, 0xfe]);
assert.ok(new TextDecoder('utf-16le').decode(octets).includes('\0'));
assertRefus(refusDe(octets), 'CARACTERE_NUL');
});
test('un UTF-32 BE ouvre sur des octets nuls : refusé comme un UTF-16 sans marque', () => {
assertRefus(refusDe(enUtf32('nom;prenom', 'be')), 'UTF16_SANS_MARQUE');
});
// 4 097 octets dont le dernier est nul, donc hors de la sonde des 4 096
// premiers : de l'UTF-8 valide, ou, avec un octet isolé en tête, du
// windows-1252.
function octetsAvecNulLoin(invalideEnUtf8) {
const octets = new Uint8Array(4097).fill(0x61);
if (invalideEnUtf8) octets[0] = 0xe9;
octets[4096] = 0;
return octets;
}
// Un caractère nul est refusé quel que soit le temps qui a décodé le texte.
// Sous une marque, les premiers octets portent le caractère nul, ou, en
// UTF-16, des octets nuls : la marque prime sur la recherche de l'octet nul,
// et le refus nomme le caractère, non un UTF-16 sans marque.
const AVEC_NUL = [
['UTF-8 avec marque', avec(MARQUE_UTF8, enUtf8('a\0b'))],
['UTF-16 LE avec marque', enUtf16('a\0b', 'le')],
['UTF-16 BE avec marque', enUtf16('a\0b', 'be')],
['UTF-8 sans marque, le nul hors de la sonde', octetsAvecNulLoin(false)],
['windows-1252, le nul hors de la sonde', octetsAvecNulLoin(true)],
];
for (const [libelle, octets] of AVEC_NUL) {
test(`${libelle} : un caractère nul dans le texte décodé est refusé, remède nommé`, () => {
assertRefus(refusDe(octets), 'CARACTERE_NUL');
});
}
test('seul le caractère nul est refusé : les autres caractères de commande passent', () => {
const texte = 'a\tb\r\nc\u0001d\u007f';
assert.deepEqual(decoder(enUtf8(texte)), { texte, encodage: 'utf-8' });
});
test('chaque refus porte ses propres détails', () => {
const premier = refusDe(enUtf16(BENOIT, 'le', false));
const second = refusDe(enUtf16(BENOIT, 'le', false));
assert.notEqual(premier.details, second.details);
premier.details.remede = 'AUTRE';
assert.equal(second.details.remede, 'ENREGISTRER_EN_UTF8');
});
});
describe('contientNul', () => {
test("vrai quand le texte porte U+0000, où qu'il soit", () => {
for (const texte of ['\0', 'a\0', '\0a', 'a\0b', 'nom;prenom\r\nBenoît\0;Exemple']) {
assert.equal(contientNul(texte), true, JSON.stringify(texte));
}
});
test('faux sans U+0000, les autres caractères de commande, le chiffre zéro et la barre oblique inverse comprises', () => {
for (const texte of ['', 'Benoît', 'a\tb\r\nc\u0001d\u007f', '0', '\\0', '\u2400']) {
assert.equal(contientNul(texte), false, JSON.stringify(texte));
}
});
});
describe('ErreurCsv', () => {
test('porte son code, ses détails et un message en JSON', () => {
const erreur = new ErreurCsv('UN_CODE', { cle: 1 });
assert.ok(erreur instanceof Error);
assert.equal(erreur.name, 'ErreurCsv');
assert.equal(erreur.code, 'UN_CODE');
assert.deepEqual(erreur.details, { cle: 1 });
assert.equal(erreur.message, 'UN_CODE {"cle":1}');
});
test('sans détails, ses détails sont un objet vide', () => {
assert.deepEqual(new ErreurCsv('SANS_DETAIL').details, {});
});
});

21
src/csv/erreurs.js Normal file
View file

@ -0,0 +1,21 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Erreurs que lève la lecture d'un CSV. Le code d'une ErreurCsv nomme le
// refus ; ses détails portent ce que l'appelant lit pour agir, comme le
// remède d'un refus d'encodage. Le message, code et détails en JSON, sert au
// diagnostic et n'est pas un texte affiché, que fournit la table des libellés
// (§ 14.6).
export class ErreurCsv extends Error {
/**
* @param {string} code
* @param {Object} [details]
*/
constructor(code, details = {}) {
super(`${code} ${JSON.stringify(details)}`);
this.code = code;
this.details = details;
}
}
ErreurCsv.prototype.name = 'ErreurCsv';

165
src/csv/lecture.js Normal file
View file

@ -0,0 +1,165 @@
// © 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');
}

500
src/csv/lecture.test.js Normal file
View file

@ -0,0 +1,500 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la lecture d'un texte CSV (§ 10.1, § 14.10) : la découpe en
// enregistrements — guillemets, guillemets doublés, fins de ligne, ligne vide,
// guillemet resté ouvert —, le choix du séparateur sur les vingt premiers
// enregistrements, et la propriété du § 14.12 qui le retrouve dans un tableau
// rendu par construction. Les en-têtes se reconnaissent ici par un double de
// l'épreuve : le choix ne dépend que du verdict, non de la manière de le
// rendre.
import assert from 'node:assert/strict';
import fc from 'fast-check';
import { describe, test } from '../../test/lanceur.js';
import { decoder } from './encodage.js';
import { ErreurCsv } from './erreurs.js';
import { choisirSeparateur, decouper, enregistrementVide } from './lecture.js';
const SEPARATEURS = [';', ',', '\t'];
// Double de la reconnaissance d'en-têtes que reçoit choisirSeparateur : les
// trois noms de colonne, tels quels.
const ENTETES = new Set(['nom', 'prenom', 'appartenance']);
const reconnaitre = (entete) => ENTETES.has(entete);
// Une reconnaissance rend un booléen, ou le nom du champ reconnu et null
// sinon : choisirSeparateur n'en lit que la valeur de vérité.
const RECONNAISSANCES = [
['qui rend un booléen', reconnaitre],
['qui rend le champ reconnu ou null', (entete) => (ENTETES.has(entete) ? entete : null)],
];
// L'erreur que lève fonction ; l'épreuve échoue quand elle ne lève rien.
function erreurDe(fonction) {
try {
fonction();
} catch (erreur) {
return erreur;
}
return assert.fail("rien n'a été levé");
}
// Vérifie que fonction lève le refus d'un séparateur introuvable.
function assertSeparateurIntrouvable(fonction) {
const erreur = erreurDe(fonction);
assert.ok(erreur instanceof ErreurCsv);
assert.equal(erreur.code, 'SEPARATEUR_INTROUVABLE');
assert.deepEqual(erreur.details, {});
}
// Enregistrements que decouper rend, une fois vérifié qu'aucun guillemet ne
// reste ouvert.
function enregistrementsDe(texte, separateur = ';') {
const { enregistrements, guillemetOuvert } = decouper(texte, separateur);
assert.equal(guillemetOuvert, false);
return enregistrements;
}
// Nombre de champs de chaque enregistrement que donne le séparateur, guillemet
// resté ouvert ou non.
const largeursLues = (texte, separateur) => decouper(texte, separateur).enregistrements.map((e) => e.length);
// Le même nombre, une fois vérifié qu'aucun guillemet ne reste ouvert.
const largeurs = (texte, separateur) => enregistrementsDe(texte, separateur).map((e) => e.length);
describe('decouper : enregistrements et champs (§ 10.1)', () => {
test('des champs séparés, une dernière ligne sans fin de ligne', () => {
assert.deepEqual(decouper('nom;prenom\nBenoît;Exemple', ';'), {
enregistrements: [
['nom', 'prenom'],
['Benoît', 'Exemple'],
],
guillemetOuvert: false,
});
});
test('un champ cité garde le séparateur ; deux guillemets y font un guillemet', () => {
assert.deepEqual(enregistrementsDe('nom;notes\r\nBenoît Exemple;"a ; b ""c"" d"\r\n'), [
['nom', 'notes'],
['Benoît Exemple', 'a ; b "c" d'],
]);
});
test('un champ cité sur deux lignes est un seul enregistrement, ses fins de ligne gardées telles quelles', () => {
assert.deepEqual(enregistrementsDe('nom;notes\r\nBenoît Exemple;"ligne 1\r\nligne 2"\nSuivant;x\r\n'), [
['nom', 'notes'],
['Benoît Exemple', 'ligne 1\r\nligne 2'],
['Suivant', 'x'],
]);
assert.deepEqual(enregistrementsDe('a;"x\ny"'), [['a', 'x\ny']]);
assert.deepEqual(enregistrementsDe('a;"x\ry"'), [['a', 'x\ry']]);
});
test('CRLF, LF ou CR terminent un enregistrement, CRLF comptant pour une seule fin', () => {
const attendu = [
['a', '1'],
['b', '2'],
['c', '3'],
];
assert.deepEqual(enregistrementsDe('a;1\r\nb;2\r\nc;3'), attendu);
assert.deepEqual(enregistrementsDe('a;1\nb;2\nc;3'), attendu);
assert.deepEqual(enregistrementsDe('a;1\rb;2\rc;3'), attendu);
assert.deepEqual(enregistrementsDe('a;1\r\nb;2\nc;3\r'), attendu);
});
test('la dernière ligne, avec ou sans fin de ligne, donne les mêmes enregistrements', () => {
for (const fin of ['', '\n', '\r\n', '\r']) {
assert.deepEqual(enregistrementsDe(`a;b${fin}`), [['a', 'b']], JSON.stringify(fin));
}
});
test("une ligne vide au milieu est un enregistrement d'un champ vide, qui garde son rang", () => {
assert.deepEqual(enregistrementsDe('nom;prenom\r\n\r\nBenoît;Exemple\r\n'), [
['nom', 'prenom'],
[''],
['Benoît', 'Exemple'],
]);
});
test("un enregistrement se désigne par son rang à partir de 1, l'en-tête compris : celui d'une ligne de tableur", () => {
const texte = [
'nom;notes', // rang 1
'Benoît Exemple;1', // rang 2
'', // rang 3 : ligne vide
'Odile Fictive;"deux\nlignes"', // rang 4 : une cellule, une seule ligne de tableur
'Rémi Témoin;3', // rang 5
].join('\r\n');
const enregistrements = enregistrementsDe(texte);
const rang = (n) => enregistrements[n - 1];
assert.equal(enregistrements.length, 5);
assert.deepEqual(rang(1), ['nom', 'notes']);
assert.deepEqual(rang(3), ['']);
assert.deepEqual(rang(4), ['Odile Fictive', 'deux\nlignes']);
assert.deepEqual(rang(5), ['Rémi Témoin', '3']);
});
test("seule la ligne vide qui suit la dernière fin de ligne n'est pas un enregistrement", () => {
assert.deepEqual(enregistrementsDe(''), []);
assert.deepEqual(enregistrementsDe('\n'), [['']]);
assert.deepEqual(enregistrementsDe('a\n\n'), [['a'], ['']]);
assert.deepEqual(enregistrementsDe('a\r\n\r\n'), [['a'], ['']]);
});
test('un dernier enregistrement réduit à un champ cité vide est un enregistrement, avec ou sans fin de ligne', () => {
assert.deepEqual(decouper('a\n""', ';'), { enregistrements: [['a'], ['']], guillemetOuvert: false });
assert.deepEqual(decouper('a\n""\n', ';'), { enregistrements: [['a'], ['']], guillemetOuvert: false });
assert.deepEqual(decouper('""', ';'), { enregistrements: [['']], guillemetOuvert: false });
});
test('un guillemet resté ouvert est signalé, et le texte lu jusque-là est rendu', () => {
assert.deepEqual(decouper('nom;notes\nBenoît;"jamais fermé\nsuite', ';'), {
enregistrements: [
['nom', 'notes'],
['Benoît', 'jamais fermé\nsuite'],
],
guillemetOuvert: true,
});
});
test('un champ cité refermé en toute fin ne laisse rien ouvert, un guillemet doublé en toute fin le laisse ouvert', () => {
assert.equal(decouper('a;"b"', ';').guillemetOuvert, false);
assert.equal(decouper('a;"b', ';').guillemetOuvert, true);
assert.equal(decouper('a;"b""', ';').guillemetOuvert, true);
assert.equal(decouper('a;"', ';').guillemetOuvert, true);
});
test('deux guillemets font un champ cité vide ; quatre, un champ réduit à un guillemet', () => {
assert.deepEqual(enregistrementsDe('a;"";c'), [['a', '', 'c']]);
assert.deepEqual(enregistrementsDe('"""";x'), [['"', 'x']]);
});
test("un guillemet qui n'ouvre pas un champ est un caractère comme un autre", () => {
assert.deepEqual(enregistrementsDe('Benoît 5" Exemple;x\nsuite;y'), [
['Benoît 5" Exemple', 'x'],
['suite', 'y'],
]);
assert.deepEqual(enregistrementsDe('a"b;c'), [['a"b', 'c']]);
});
test("ce qui suit le guillemet fermant, jusqu'au séparateur, reste dans le champ", () => {
assert.deepEqual(enregistrementsDe('"Benoît" ;x'), [['Benoît ', 'x']]);
assert.deepEqual(enregistrementsDe('"a"b;c'), [['ab', 'c']]);
});
test('les blancs autour des valeurs sont rendus tels quels, cités ou non', () => {
assert.deepEqual(enregistrementsDe(' Benoît ; Exemple ;" Odile "'), [[' Benoît ', ' Exemple ', ' Odile ']]);
});
test('les champs vides, aux deux bouts comme au milieu', () => {
assert.deepEqual(enregistrementsDe(';a;;b;'), [['', 'a', '', 'b', '']]);
assert.deepEqual(enregistrementsDe(';;'), [['', '', '']]);
});
test('la tabulation sépare, et se cite comme tout séparateur', () => {
assert.deepEqual(enregistrementsDe('nom\tprenom\nExemple\t"a\tb"', '\t'), [
['nom', 'prenom'],
['Exemple', 'a\tb'],
]);
});
test('sans séparateur (null), chaque enregistrement est un seul champ, virgules et points-virgules compris', () => {
assert.deepEqual(enregistrementsDe('nom\nBenoît Exemple, Odile\n"Rémi; ""R"""', null), [
['nom'],
['Benoît Exemple, Odile'],
['Rémi; "R"'],
]);
});
});
describe('enregistrementVide', () => {
test('vrai quand chaque champ est vide ou réduit à des blancs', () => {
assert.equal(enregistrementVide(['']), true);
assert.equal(enregistrementVide(['', '', '']), true);
assert.equal(enregistrementVide([' ', '\t']), true);
});
test("faux dès qu'un champ porte un caractère", () => {
assert.equal(enregistrementVide(['', 'a']), false);
assert.equal(enregistrementVide(['0']), false);
});
});
describe('choisirSeparateur : le candidat retenu (§ 10.1)', () => {
test("un fichier séparé par des virgules : « ; » est écarté parce qu'il donne un champ, non faute de constance", () => {
// « ; » donne un champ à chaque enregistrement : la constance seule le
// retiendrait, et, sans en-tête reconnu pour départager, l'ordre des
// candidats le placerait devant « , ».
const texte = 'a,b,c\n1,2,3\n';
assert.deepEqual(largeurs(texte, ';'), [1, 1]);
assert.deepEqual(choisirSeparateur(texte, () => false), { separateur: ',' });
});
test('un fichier séparé par des virgules, ses en-têtes reconnus', () => {
const texte = 'nom,prenom,appartenance\nBenoît,Exemple,Groupe Azur\nOdile,Fictive,Groupe Ambre\n';
assert.deepEqual(choisirSeparateur(texte, reconnaitre), { separateur: ',' });
});
test('un collage de tableur est séparé par des tabulations, même quand un champ porte une virgule', () => {
const texte = 'nom\tprenom\tappartenance\nBenoît\tExemple\tGroupe Azur, nord\nOdile\tFictive\tGroupe Ambre\n';
assert.deepEqual(largeurs(texte, ','), [1, 2, 1]);
assert.deepEqual(choisirSeparateur(texte, reconnaitre), { separateur: '\t' });
});
// « ; » donne deux champs, « , » trois, l'un et l'autre constants : seule la
// reconnaissance des en-têtes les départage.
const TEXTE_A_EGALITE = 'nom,prenom,appartenance;x\nBenoît,Exemple,Groupe Azur;y\n';
for (const [libelle, reconnaissance] of RECONNAISSANCES) {
test(`égalité de constance : le candidat aux plus d'en-têtes reconnus, fût-il le dernier essayé (reconnaissance ${libelle})`, () => {
assert.deepEqual(largeurs(TEXTE_A_EGALITE, ';'), [2, 2]);
assert.deepEqual(largeurs(TEXTE_A_EGALITE, ','), [3, 3]);
// « , » reconnaît deux en-têtes (nom, prenom), « ; » aucun.
assert.deepEqual(choisirSeparateur(TEXTE_A_EGALITE, reconnaissance), { separateur: ',' });
});
}
test("la même égalité, une reconnaissance qui favorise l'autre lecture : le choix suit le verdict", () => {
const lectureParPointVirgule = (entete) => entete === 'x' || entete === 'nom,prenom,appartenance';
assert.deepEqual(choisirSeparateur(TEXTE_A_EGALITE, lectureParPointVirgule), { separateur: ';' });
});
test("égalité complète : l'ordre « ; », « , », tabulation départage", () => {
const aucune = () => false;
assert.deepEqual(choisirSeparateur('a,b;c\nd,e;f\n', aucune), { separateur: ';' });
assert.deepEqual(choisirSeparateur('a,b\tc\nd,e\tf\n', aucune), { separateur: ',' });
});
test("un en-tête non reconnu n'écarte pas le candidat : la reconnaissance ne fait que départager", () => {
assert.deepEqual(choisirSeparateur('a;b\nc;d\n', () => false), { separateur: ';' });
});
test("reconnaitre reçoit les champs du premier enregistrement non vide, tels qu'écrits", () => {
const appels = [];
choisirSeparateur('\n Nom ;prenom\nA;B\n', (entete) => {
appels.push(entete);
return false;
});
assert.deepEqual(appels, [' Nom ', 'prenom']);
});
test("une colonne d'en-tête vide compte comme un champ", () => {
assert.deepEqual(choisirSeparateur('nom;;appartenance\nBenoît;;Groupe Azur\n', reconnaitre), { separateur: ';' });
assert.deepEqual(choisirSeparateur('nom;prenom;\nBenoît;Exemple;\n', reconnaitre), { separateur: ';' });
});
test('un fichier réduit à son en-tête donne son séparateur', () => {
assert.deepEqual(choisirSeparateur('nom;prenom', reconnaitre), { separateur: ';' });
});
test('les enregistrements vides — ligne vide, champs vides, blancs seuls — ne rompent pas la constance', () => {
const texte = 'nom;prenom\r\n\r\nBenoît;Exemple\r\n;\r\n \r\nOdile;Fictive\r\n';
assert.deepEqual(choisirSeparateur(texte, reconnaitre), { separateur: ';' });
});
describe('la fenêtre des vingt premiers enregistrements', () => {
// Vingt-cinq enregistrements de trois champs, dont celui du rang donné,
// à partir de 1, est remplacé.
const texteAvec = (rang, remplacement) => {
const lignes = [
'nom;prenom;appartenance',
...Array.from({ length: 24 }, (_, i) => `n${i};p${i};a${i}`),
];
lignes[rang - 1] = remplacement;
return lignes.join('\n');
};
test('un enregistrement plus long au rang 20 écarte « ; », au rang 21 non', () => {
assertSeparateurIntrouvable(() => choisirSeparateur(texteAvec(20, 'x;y;z;t'), reconnaitre));
assert.deepEqual(choisirSeparateur(texteAvec(21, 'x;y;z;t'), reconnaitre), { separateur: ';' });
});
test('un enregistrement plus court au rang 20 écarte « ; », au rang 21 non', () => {
assertSeparateurIntrouvable(() => choisirSeparateur(texteAvec(20, 'x;y'), reconnaitre));
assert.deepEqual(choisirSeparateur(texteAvec(21, 'x;y'), reconnaitre), { separateur: ';' });
});
test('un guillemet resté ouvert depuis le rang 20 écarte « ; », depuis le rang 21 non', () => {
// Le dernier champ de l'enregistrement avale la fin du fichier : le
// nombre de champs reste trois, seul le guillemet ouvert écarte « ; ».
const ouvert = 'x;y;"ouvert';
assert.deepEqual(largeursLues(texteAvec(20, ouvert), ';'), new Array(20).fill(3));
assertSeparateurIntrouvable(() => choisirSeparateur(texteAvec(20, ouvert), reconnaitre));
const texte = texteAvec(21, ouvert);
assert.deepEqual(choisirSeparateur(texte, reconnaitre), { separateur: ';' });
// Le découpage du fichier entier, lui, voit le guillemet resté ouvert.
assert.equal(decouper(texte, ';').guillemetOuvert, true);
});
});
});
describe('choisirSeparateur : un fichier à une seule colonne (§ 10.1)', () => {
test('une colonne « nom » dont une ligne porte une virgule : une colonne, la virgule gardée dans le nom', () => {
const texte = 'nom\nBenoît Exemple, Odile\nRémi Témoin\n';
// La virgule donne un champ, puis deux, puis un : elle n'est pas constante.
assert.deepEqual(largeurs(texte, ','), [1, 2, 1]);
assert.deepEqual(choisirSeparateur(texte, reconnaitre), { separateur: null });
assert.deepEqual(enregistrementsDe(texte, null), [['nom'], ['Benoît Exemple, Odile'], ['Rémi Témoin']]);
});
test("la première ligne entière, ses blancs de bord retirés, est celle qu'on reconnaît", () => {
assert.deepEqual(choisirSeparateur(' nom \nBenoît Exemple\n', reconnaitre), { separateur: null });
});
test("une ligne vide avant l'en-tête n'empêche pas de le reconnaître", () => {
assert.deepEqual(choisirSeparateur('\r\n nom\r\nBenoît Exemple\r\n', reconnaitre), { separateur: null });
});
test('un en-tête cité se reconnaît comme le même sans guillemets', () => {
assert.deepEqual(choisirSeparateur('"nom"\nBenoît Exemple\n', reconnaitre), { separateur: null });
});
test("une colonne unique n'est retenue que faute de candidat", () => {
assert.deepEqual(choisirSeparateur('nom;prenom\nBenoît;Exemple\n', reconnaitre), { separateur: ';' });
});
});
describe('choisirSeparateur : le séparateur introuvable (§ 10.1)', () => {
test("une seule colonne dont la première ligne n'est pas un en-tête reconnu", () => {
assertSeparateurIntrouvable(() => choisirSeparateur('Benoît Exemple\nOdile Fictive\n', reconnaitre));
});
test('un nombre de champs qui varie, pour chaque candidat : un enregistrement plus long', () => {
assertSeparateurIntrouvable(() =>
choisirSeparateur('nom;prenom\nBenoît;Exemple;Groupe Azur\n', reconnaitre),
);
});
test("un nombre de champs qui varie, pour chaque candidat : un enregistrement plus court, jusqu'à un seul champ", () => {
assertSeparateurIntrouvable(() =>
choisirSeparateur('nom;prenom;appartenance\nBenoît;Exemple\n', reconnaitre),
);
assertSeparateurIntrouvable(() =>
choisirSeparateur('nom;prenom\nBenoît;Exemple\nune ligne sans séparateur\n', reconnaitre),
);
});
test('un guillemet resté ouvert dans les premiers enregistrements, même quand le nombre de champs reste constant', () => {
assertSeparateurIntrouvable(() =>
choisirSeparateur('nom;prenom\n"Benoît;Exemple\nOdile;Fictive\n', reconnaitre),
);
// Deux champs partout, mais le second de la dernière ligne avale la fin du
// fichier : seul le guillemet resté ouvert écarte « ; ».
const texte = 'nom;prenom\nBenoît;"Exemple\nOdile;Fictive\n';
const lu = decouper(texte, ';');
assert.deepEqual(lu.enregistrements.map((e) => e.length), [2, 2]);
assert.equal(lu.guillemetOuvert, true);
assertSeparateurIntrouvable(() => choisirSeparateur(texte, reconnaitre));
});
test('un texte vide, ou fait de lignes vides', () => {
assertSeparateurIntrouvable(() => choisirSeparateur('', reconnaitre));
assertSeparateurIntrouvable(() => choisirSeparateur('\r\n\r\n', reconnaitre));
});
});
describe("de l'octet à l'enregistrement : un CSV tel qu'un tableur l'écrit", () => {
test('marque UTF-8 et CRLF, champ cité à guillemets doublés, ligne vide, blancs, colonne sans en-tête, dernière ligne sans fin', () => {
const texteTableur = [
'nom;prenom;appartenance;;notes',
'Exemple;Benoît;Groupe Azur;;"dit ""bonjour"" ; puis part"',
'',
' Fictif ; Odile ;;;',
'Témoin;Rémi;Groupe Ambre;;fin',
].join('\r\n');
const octets = Uint8Array.from([0xef, 0xbb, 0xbf, ...new TextEncoder().encode(texteTableur)]);
const { texte, encodage } = decoder(octets);
assert.equal(encodage, 'utf-8-bom');
const { separateur } = choisirSeparateur(texte, reconnaitre);
assert.equal(separateur, ';');
assert.deepEqual(decouper(texte, separateur), {
enregistrements: [
['nom', 'prenom', 'appartenance', '', 'notes'],
['Exemple', 'Benoît', 'Groupe Azur', '', 'dit "bonjour" ; puis part'],
[''],
[' Fictif ', ' Odile ', '', '', ''],
['Témoin', 'Rémi', 'Groupe Ambre', '', 'fin'],
],
guillemetOuvert: false,
});
});
});
describe('choisirSeparateur et decouper : propriété par construction (§ 14.12)', () => {
// Graine fixée, écrite ici : un contre-exemple se rejoue.
const GRAINE = 161_803;
const TIRAGES = 300;
// Unités des champs engendrés : trois fois sur quatre une lettre, un accent
// ou un blanc, sinon un guillemet, une fin de ligne ou le séparateur connu,
// qui forcent la citation. Les deux autres candidats en sont absents : un
// tableau qui les porte se lit de plusieurs façons, et le départage par les
// en-têtes s'éprouve à part.
const unite = (separateur) =>
fc.oneof(
{ arbitrary: fc.constantFrom('a', 'Z', 'é', ' '), weight: 3 },
{ arbitrary: fc.constantFrom('"', '\n', '\r', separateur), weight: 1 },
);
const champ = (separateur) => fc.string({ unit: unite(separateur), maxLength: 6 });
// Un champ qui commence par une lettre n'est jamais vide, même une fois ses
// blancs retirés : aucun enregistrement du tableau n'est un enregistrement
// vide, que le choix du séparateur ignore.
const premierChamp = (separateur) =>
fc
.tuple(fc.constantFrom('a', 'Z', 'é'), champ(separateur))
.map(([tete, suite]) => tete + suite);
// Un tableau de 1 à 25 enregistrements, de 2 à 5 champs chacun, et la
// manière de l'écrire : séparateur, fin de ligne, fin de ligne finale ou non.
const cas = fc.constantFrom(...SEPARATEURS).chain((separateur) =>
fc.record({
separateur: fc.constant(separateur),
tableau: fc.integer({ min: 2, max: 5 }).chain((largeur) =>
fc.array(
fc
.tuple(premierChamp(separateur), fc.array(champ(separateur), { minLength: largeur - 1, maxLength: largeur - 1 }))
.map(([premier, autres]) => [premier, ...autres]),
{ minLength: 1, maxLength: 25, size: 'max' },
),
),
fin: fc.constantFrom('\r\n', '\n', '\r'),
finale: fc.boolean(),
}),
);
// Écrit le tableau comme un tableur : un champ qui porte le séparateur, un
// guillemet ou une fin de ligne se cite, ses guillemets doublés.
function ecrire({ separateur, tableau, fin, finale }) {
const ecrireChamp = (valeur) =>
valeur.includes(separateur) || /["\r\n]/.test(valeur) ? `"${valeur.replaceAll('"', '""')}"` : valeur;
const lignes = tableau.map((ligne) => ligne.map(ecrireChamp).join(separateur));
return lignes.join(fin) + (finale ? fin : '');
}
test('le séparateur est retrouvé, et le découpage rend le tableau', () => {
const bilan = { tirages: 0, separateurCite: 0, champSurPlusieursLignes: 0, auDelaDeLaFenetre: 0 };
fc.assert(
fc.property(cas, (tirage) => {
const texte = ecrire(tirage);
bilan.tirages += 1;
const champs = tirage.tableau.flat();
if (champs.some((valeur) => valeur.includes(tirage.separateur))) bilan.separateurCite += 1;
if (champs.some((valeur) => /[\r\n]/.test(valeur))) bilan.champSurPlusieursLignes += 1;
if (tirage.tableau.length > 20) bilan.auDelaDeLaFenetre += 1;
assert.deepEqual(choisirSeparateur(texte, () => false), { separateur: tirage.separateur });
assert.deepEqual(decouper(texte, tirage.separateur), {
enregistrements: tirage.tableau,
guillemetOuvert: false,
});
}),
{ seed: GRAINE, numRuns: TIRAGES },
);
// Un générateur qui n'engendre pas les cas difficiles rend la propriété
// vraie sans rien éprouver : chaque famille de cas a son plancher.
assert.equal(bilan.tirages, TIRAGES);
assert.ok(bilan.separateurCite >= 100, `séparateur cité : ${bilan.separateurCite}`);
assert.ok(bilan.champSurPlusieursLignes >= 100, `champ sur plusieurs lignes : ${bilan.champSurPlusieursLignes}`);
assert.ok(bilan.auDelaDeLaFenetre >= 20, `au-delà de la fenêtre : ${bilan.auDelaDeLaFenetre}`);
});
});