Screenshots and guides must stay in step with the software they show. The manifest names each screenshot, its stage and its fingerprint; the checks refuse a missing or out-of-order image, a guide reference to an unknown capture, a wrong PNG size, and any absolute path outside the neutral root. Captures planned for a later iteration are named as postponed and admitted. Checked: 50 tests red first; 3169 node tests green from the index alone. --- FR --- [ADD] docs : manifeste des captures, contrôles d'ordre, chemins, images Les captures et les guides doivent suivre le logiciel qu'ils montrent. Le manifeste nomme chaque capture, son étape et son empreinte ; les contrôles refusent une image absente ou hors d'ordre, un guide qui cite une capture inconnue, une taille de PNG fausse, et tout chemin absolu hors de la racine neutre. Les captures prévues pour une itération ultérieure sont nommées reportées, et admises. Vérifié : 50 épreuves rouges d'abord ; 3169 node verts depuis l'index seul. Assisted-by: Claude Opus 5.5
581 lines
27 KiB
JavaScript
581 lines
27 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||
|
||
// Le manifeste des captures et ses contrôles du niveau node (§ 19.3, § 19.5,
|
||
// § 19.7). Pur : aucune lecture de fichier, aucune horloge. L'appelant lit le
|
||
// manifeste, la documentation et le dossier des images, puis passe leurs
|
||
// contenus ; chaque contrôle rend la liste de ses fautes, vide quand tout
|
||
// tient, et refuse de passer sur un balayage vide.
|
||
//
|
||
// Un nom de capture (« fichier ») n'a pas d'extension : l'image est
|
||
// doc/captures/<fichier>.png. L'ordre déclaré des captures et des reportées
|
||
// est celui de leurs noms, comparés unité de code par unité de code : le
|
||
// préfixe NN, puis « a », « b », range chaque entrée à sa place dans le guide.
|
||
//
|
||
// Une faute est { chemin, faute } : le chemin du fichier en cause, relatif à
|
||
// la racine du projet (l'image d'une capture, le manifeste, un document), et
|
||
// la nature de la faute en toutes lettres.
|
||
import { createHash } from 'node:crypto';
|
||
import { VERSION } from '../../src/version.genere.js';
|
||
|
||
/** @typedef {{chemin: string, faute: string}} Faute */
|
||
|
||
export const DOSSIER_CAPTURES = 'doc/captures';
|
||
export const CHEMIN_MANIFESTE = `${DOSSIER_CAPTURES}/manifeste.json`;
|
||
|
||
/** Chemin de l'image d'une capture, relatif à la racine du projet. */
|
||
export function cheminImage(fichier) {
|
||
return `${DOSSIER_CAPTURES}/${fichier}.png`;
|
||
}
|
||
|
||
// Les treize étapes du parcours de l'opérateur (§ 2.1), dans leur ordre : la
|
||
// seule source de l'ordre des captures (§ 19.3).
|
||
export const ETAPES = Object.freeze(['1', '1b', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12']);
|
||
|
||
const capture = (fichier, etape, configuration, source = 'navigateur') => Object.freeze({ fichier, etape, configuration, source });
|
||
|
||
// La table du § 19.3 en données, avec 02b, 07a et 07b. « configuration » nomme
|
||
// ce que la capture exige : le répertoire neuf, le fichier d'import
|
||
// d'exemples/, l'identifiant de démonstration du catalogue, ou l'événement que
|
||
// le parcours construit puis retient.
|
||
export const CAPTURES = Object.freeze([
|
||
capture('01-premier-ecran', '1', 'repertoire-neuf'),
|
||
capture('01b-passer-en-ecriture', '1b', 'repertoire-neuf'),
|
||
capture('02-import-apercu', '2', 'participants_cas_limites.csv'),
|
||
capture('02b-import-cp1252', '2', 'participants_cas_limites_cp1252.csv'),
|
||
capture('03-import-rapport', '2', 'participants_cas_limites.csv'),
|
||
capture('04-grille-participants', '3', 'evenement-construit'),
|
||
capture('05-tables-etat', '4', 'evenement-construit'),
|
||
capture('06-defaut-consequence', '4', 'evenement-construit'),
|
||
capture('07-plan-tables', '4', 'evenement-construit'),
|
||
capture('07a-cibles-sieges', '4', 'evenement-construit'),
|
||
capture('07a-cibles-tables', '4', 'evenement-construit'),
|
||
capture('07b-nombre-de-tours', '5', 'evenement-construit'),
|
||
capture('08-animateurs-designes', '6', 'evenement-construit'),
|
||
capture('09-refus-sur-place', '7', 'evenement-construit'),
|
||
capture('10-diagnostic', '8', 'grande'),
|
||
capture('11-propositions', '9', 'evenement-construit'),
|
||
capture('19-ajustement', '11', 'evenement-retenu'),
|
||
capture('20-mode-lecture', '11', 'evenement-retenu'),
|
||
capture('22-plan-brouillon', '12', 'evenement-retenu'),
|
||
capture('23-plan-retenu', '12', 'evenement-retenu'),
|
||
capture('24-badges-controle', '12', 'evenement-retenu', 'pdf'),
|
||
capture('25-badges-planche', '12', 'evenement-retenu', 'pdf'),
|
||
]);
|
||
|
||
const reportee = (fichier, etape) => Object.freeze({ fichier, etape, raison: 'iteration5' });
|
||
|
||
// Les captures des écrans que l'itération 5 livre (Q8 a) : déclarées à leur
|
||
// étape, sans image ; le contrôle d'ordre les compte comme présentes.
|
||
export const REPORTEES = Object.freeze([
|
||
reportee('12-qualite-profil', '10'),
|
||
reportee('13-qualite-collisions', '10'),
|
||
reportee('14-qualite-paires', '10'),
|
||
reportee('15-pas-de-matrice', '10'),
|
||
reportee('16-minimum-atteint', '10'),
|
||
reportee('17-comparaison-manque', '10'),
|
||
reportee('18-comparaison-escalier', '10'),
|
||
reportee('21-historique', '11'),
|
||
]);
|
||
|
||
// Captures déclarées dans CAPTURES et pas encore photographiées : la
|
||
// publication et le contrôle d'ordre les admettent absentes du manifeste.
|
||
export const A_VENIR = Object.freeze(['22-plan-brouillon', '23-plan-retenu', '24-badges-controle', '25-badges-planche']);
|
||
|
||
// --- Forme canonique et empreintes ---------------------------------------------
|
||
|
||
const comparer = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||
|
||
function estObjet(valeur) {
|
||
return typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
|
||
}
|
||
|
||
// JSON d'une valeur, les clés de chaque objet triées par unités de code, sans
|
||
// espace. Lève TypeError sur ce que JSON ne porte pas fidèlement : undefined,
|
||
// fonction, symbole, grand entier, nombre non fini.
|
||
function json(valeur) {
|
||
if (valeur === null || typeof valeur === 'string' || typeof valeur === 'boolean') return JSON.stringify(valeur);
|
||
if (typeof valeur === 'number') {
|
||
if (!Number.isFinite(valeur)) throw new TypeError(`nombre non fini dans le manifeste : ${valeur}`);
|
||
return JSON.stringify(valeur);
|
||
}
|
||
if (Array.isArray(valeur)) return `[${valeur.map(json).join(',')}]`;
|
||
if (estObjet(valeur)) {
|
||
const cles = Object.keys(valeur).sort(comparer);
|
||
return `{${cles.map((cle) => `${JSON.stringify(cle)}:${json(valeur[cle])}`).join(',')}}`;
|
||
}
|
||
throw new TypeError(`valeur que JSON ne porte pas dans le manifeste : ${typeof valeur}`);
|
||
}
|
||
|
||
const sha256 = (donnees) => createHash('sha256').update(donnees).digest('hex');
|
||
|
||
/**
|
||
* Le manifeste en JSON canonique : clés triées à toute profondeur, chaque
|
||
* élément d'un tableau de premier niveau sur sa ligne (une capture, une
|
||
* reportée), le reste sur la ligne de sa clé, une fin de ligne finale. Deux
|
||
* manifestes égaux rendent les mêmes octets, quel que soit l'ordre de leurs
|
||
* clés ; l'ordre des tableaux est conservé.
|
||
* @param {object} manifeste
|
||
* @returns {string}
|
||
*/
|
||
export function canoniser(manifeste) {
|
||
if (!estObjet(manifeste)) throw new TypeError('le manifeste est un objet');
|
||
const champs = Object.keys(manifeste).sort(comparer).map((cle) => {
|
||
const valeur = manifeste[cle];
|
||
const tete = `${JSON.stringify(cle)}:`;
|
||
if (Array.isArray(valeur) && valeur.length > 0) return `${tete}[\n${valeur.map(json).join(',\n')}\n]`;
|
||
return tete + json(valeur);
|
||
});
|
||
return `{${champs.join(',')}}\n`;
|
||
}
|
||
|
||
/**
|
||
* Empreinte d'étape (§ 19.5, amendé) : SHA-256 hexadécimal du JSON canonique
|
||
* de l'entrée, sans image, empreinte, versionReelle ni versionNavigateur, à
|
||
* plat ou sous pilote. La version réelle et celle du navigateur changent
|
||
* d'une livraison et d'un poste à l'autre sans que l'état montré change :
|
||
* les garder réécrirait chaque image à chaque incrément.
|
||
* @param {object} entree une entrée de manifeste.captures
|
||
* @returns {string}
|
||
*/
|
||
export function empreinteEtape(entree) {
|
||
if (!estObjet(entree)) throw new TypeError('une entrée de manifeste est un objet');
|
||
const { image: _image, empreinte: _empreinte, versionReelle: _version, versionNavigateur: _navigateur, ...reste } = entree;
|
||
if (estObjet(reste.pilote)) {
|
||
const { versionNavigateur: _versionPilote, ...pilote } = reste.pilote;
|
||
reste.pilote = pilote;
|
||
}
|
||
return sha256(json(reste));
|
||
}
|
||
|
||
// --- Appariement documentation, manifeste, disque --------------------------------
|
||
|
||
const MOTIF_REFERENCE = /doc\/captures\/[A-Za-z0-9._-]+\.png/g;
|
||
|
||
/**
|
||
* Les images de doc/captures qu'un document référence, sous la forme
|
||
* doc/captures/<nom>.png quel que soit le préfixe relatif qui les précède,
|
||
* chacune une fois, dans l'ordre de leur première apparition.
|
||
* @param {string} texte
|
||
* @returns {string[]}
|
||
*/
|
||
export function imagesReferencees(texte) {
|
||
if (typeof texte !== 'string') throw new TypeError('le texte d’un document est une chaîne');
|
||
return [...new Set(texte.match(MOTIF_REFERENCE) ?? [])];
|
||
}
|
||
|
||
function exigerTableau(valeur, nom) {
|
||
if (!Array.isArray(valeur)) throw new TypeError(`${nom} est un tableau`);
|
||
}
|
||
|
||
function capturesDe(manifeste) {
|
||
if (!estObjet(manifeste)) throw new TypeError('le manifeste est un objet');
|
||
const captures = manifeste.captures ?? [];
|
||
exigerTableau(captures, 'manifeste.captures');
|
||
return captures;
|
||
}
|
||
|
||
const enListe = (parties) => (parties.length === 1 ? parties[0] : `${parties.slice(0, -1).join(', ')} et ${parties.at(-1)}`);
|
||
|
||
/**
|
||
* L'appariement du § 19.7 : toute image référencée par la documentation a
|
||
* son entrée au manifeste et son fichier, toute entrée et tout fichier sont
|
||
* référencés. Une image absente d'un côté au moins est une orpheline : une
|
||
* faute par image, qui dit où elle est et d'où elle manque. Un manifeste sans
|
||
* capture, une documentation vide, un document sans image, un dossier sans
|
||
* image sont chacun une faute.
|
||
* @param {{manifeste: object, references: Array<{document: string, images: string[]}>, images: string[]}} entrees
|
||
* references : par document, les chemins doc/captures/<nom>.png qu'il
|
||
* référence ; images : les chemins des PNG présents sur le disque
|
||
* @returns {Faute[]} les balayages vides, puis les images par chemin
|
||
*/
|
||
export function verifierAppariement({ manifeste, references, images }) {
|
||
const captures = capturesDe(manifeste);
|
||
exigerTableau(references, 'references');
|
||
exigerTableau(images, 'images');
|
||
const fautes = [];
|
||
if (captures.length === 0) fautes.push({ chemin: CHEMIN_MANIFESTE, faute: 'aucune capture au manifeste' });
|
||
if (references.length === 0) fautes.push({ chemin: CHEMIN_MANIFESTE, faute: 'aucun document ne référence d’image' });
|
||
if (images.length === 0) fautes.push({ chemin: DOSSIER_CAPTURES, faute: 'aucune image sur le disque' });
|
||
|
||
const documentsDe = new Map();
|
||
for (const { document, images: referencees } of references) {
|
||
exigerTableau(referencees, `les images de ${document}`);
|
||
if (referencees.length === 0) fautes.push({ chemin: document, faute: 'aucune image référencée' });
|
||
for (const image of referencees) {
|
||
const documents = documentsDe.get(image) ?? [];
|
||
if (!documents.includes(document)) documents.push(document);
|
||
documentsDe.set(image, documents);
|
||
}
|
||
}
|
||
const auManifeste = new Set();
|
||
const doubles = new Set();
|
||
for (const entree of captures) {
|
||
const chemin = cheminImage(entree.fichier);
|
||
if (auManifeste.has(chemin)) doubles.add(chemin);
|
||
auManifeste.add(chemin);
|
||
}
|
||
const surDisque = new Set(images);
|
||
|
||
const tous = [...new Set([...documentsDe.keys(), ...auManifeste, ...surDisque])].sort(comparer);
|
||
for (const chemin of tous) {
|
||
if (doubles.has(chemin)) fautes.push({ chemin, faute: 'deux fois au manifeste' });
|
||
const presences = [];
|
||
const absences = [];
|
||
if (documentsDe.has(chemin)) presences.push(`référencée par ${documentsDe.get(chemin).join(', ')}`);
|
||
else absences.push('de la documentation');
|
||
if (auManifeste.has(chemin)) presences.push('au manifeste');
|
||
else absences.push('du manifeste');
|
||
if (surDisque.has(chemin)) presences.push('sur le disque');
|
||
else absences.push('du disque');
|
||
if (absences.length > 0) fautes.push({ chemin, faute: `orpheline : ${presences.join(', ')} ; absente ${enListe(absences)}` });
|
||
}
|
||
return fautes;
|
||
}
|
||
|
||
// --- Ordre contre les étapes ------------------------------------------------------
|
||
|
||
/**
|
||
* Le contrôle d'ordre du § 19.3 : les captures et les reportées du
|
||
* manifeste, plus chaque capture d'aVenir absente du manifeste à son étape
|
||
* déclarée dans CAPTURES, rangées par nom. Faute : une étape hors d'ETAPES ;
|
||
* un nom déclaré deux fois ; une entrée dont l'étape précède celle de
|
||
* l'entrée connue qui la devance ; une étape d'ETAPES sans aucune entrée ; un
|
||
* manifeste sans aucune entrée.
|
||
* @param {object} manifeste
|
||
* @param {{aVenir?: readonly string[]}} [options] A_VENIR par défaut
|
||
* @returns {Faute[]} les fautes d'entrée dans l'ordre des noms, puis les étapes manquantes
|
||
*/
|
||
export function verifierOrdre(manifeste, { aVenir = A_VENIR } = {}) {
|
||
const captures = capturesDe(manifeste);
|
||
const reportees = manifeste.reportees ?? [];
|
||
exigerTableau(reportees, 'manifeste.reportees');
|
||
const presentes = new Set(captures.map((c) => c.fichier));
|
||
const admises = CAPTURES.filter((c) => aVenir.includes(c.fichier) && !presentes.has(c.fichier));
|
||
const entrees = [...captures, ...reportees, ...admises]
|
||
.map(({ fichier, etape }) => ({ fichier, etape }))
|
||
.sort((a, b) => comparer(a.fichier, b.fichier));
|
||
|
||
const fautes = [];
|
||
if (entrees.length === 0) fautes.push({ chemin: CHEMIN_MANIFESTE, faute: 'aucune capture ni reportée' });
|
||
const vues = new Set();
|
||
const couvertes = new Set();
|
||
let precedente = null;
|
||
for (const entree of entrees) {
|
||
const chemin = cheminImage(entree.fichier);
|
||
if (vues.has(entree.fichier)) fautes.push({ chemin, faute: 'déclarée deux fois' });
|
||
vues.add(entree.fichier);
|
||
const rang = ETAPES.indexOf(entree.etape);
|
||
if (rang < 0) {
|
||
fautes.push({ chemin, faute: `étape inconnue « ${entree.etape} »` });
|
||
continue;
|
||
}
|
||
couvertes.add(entree.etape);
|
||
if (precedente !== null && rang < precedente.rang) {
|
||
fautes.push({
|
||
chemin,
|
||
faute: `étape ${entree.etape} après ${precedente.fichier}, de l’étape ${precedente.etape} : l’ordre contredit les étapes`,
|
||
});
|
||
}
|
||
precedente = { ...entree, rang };
|
||
}
|
||
for (const etape of ETAPES) {
|
||
if (!couvertes.has(etape)) fautes.push({ chemin: CHEMIN_MANIFESTE, faute: `étape ${etape} : aucune capture ni reportée` });
|
||
}
|
||
return fautes;
|
||
}
|
||
|
||
// --- Version ----------------------------------------------------------------------
|
||
|
||
/**
|
||
* Écart entre la version réelle inscrite au manifeste et celle de la
|
||
* construction (§ 19.7) : null quand chaque capture porte la version
|
||
* attendue ; sinon { manifeste, construction }, où manifeste nomme les
|
||
* versions distinctes des captures, dans leur ordre, séparées par « , »
|
||
* (« absente » pour une capture qui n'en porte pas), et vaut null pour un
|
||
* manifeste sans capture, qui n'établit aucune version.
|
||
* @param {object} manifeste
|
||
* @param {string} [version] VERSION.affichee par défaut
|
||
* @returns {null | {manifeste: string|null, construction: string}}
|
||
*/
|
||
export function ecartDeVersion(manifeste, version = VERSION.affichee) {
|
||
if (typeof version !== 'string') throw new TypeError('la version de la construction est une chaîne');
|
||
const captures = capturesDe(manifeste);
|
||
if (captures.length === 0) return { manifeste: null, construction: version };
|
||
const versions = [...new Set(captures.map((c) => (typeof c.versionReelle === 'string' ? c.versionReelle : 'absente')))];
|
||
if (versions.length === 1 && versions[0] === version) return null;
|
||
return { manifeste: versions.join(', '), construction: version };
|
||
}
|
||
|
||
// --- Image --------------------------------------------------------------------------
|
||
|
||
const SIGNATURE_PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||
const LIMITE_PNG = 2 ** 31 - 1;
|
||
|
||
/**
|
||
* Largeur et hauteur d'un PNG, lues dans son morceau IHDR, qui suit la
|
||
* signature (octets 16 à 23, gros-boutiste). Lève sur une signature fausse,
|
||
* un en-tête tronqué, un premier morceau qui n'est pas un IHDR de 13 octets,
|
||
* une dimension nulle ou au-delà de 2³¹ − 1.
|
||
* @param {Uint8Array} octets
|
||
* @returns {{largeur: number, hauteur: number}}
|
||
*/
|
||
export function lireEntetePng(octets) {
|
||
if (!(octets instanceof Uint8Array)) throw new TypeError('les octets d’une image sont un Uint8Array');
|
||
if (octets.length < SIGNATURE_PNG.length || SIGNATURE_PNG.some((octet, rang) => octets[rang] !== octet)) {
|
||
throw new Error('signature PNG absente');
|
||
}
|
||
if (octets.length < 24) throw new Error(`en-tête PNG tronqué : ${octets.length} octets`);
|
||
const vue = new DataView(octets.buffer, octets.byteOffset, octets.byteLength);
|
||
const type = String.fromCharCode(...octets.subarray(12, 16));
|
||
if (vue.getUint32(8) !== 13 || type !== 'IHDR') throw new Error('premier morceau PNG autre qu’un IHDR de 13 octets');
|
||
const largeur = vue.getUint32(16);
|
||
const hauteur = vue.getUint32(20);
|
||
if (largeur === 0 || largeur > LIMITE_PNG) throw new Error(`largeur PNG hors domaine : ${largeur}`);
|
||
if (hauteur === 0 || hauteur > LIMITE_PNG) throw new Error(`hauteur PNG hors domaine : ${hauteur}`);
|
||
return { largeur, hauteur };
|
||
}
|
||
|
||
/**
|
||
* Les fautes du fichier image d'une entrée : son SHA-256 diffère de
|
||
* image.sha256 (une image remplacée, par une version antérieure ou par une
|
||
* autre) ; il ne se lit pas comme un PNG ; ses dimensions diffèrent du cadre
|
||
* multiplié par le facteur d'échelle (arrondi à l'entier), ou de celles
|
||
* qu'inscrit image. Le contrôle porte sur les octets du fichier, jamais sur
|
||
* des pixels (§ 19.4).
|
||
* @param {object} entree une entrée de manifeste.captures
|
||
* @param {Uint8Array} octets le contenu du fichier
|
||
* @returns {Faute[]}
|
||
*/
|
||
export function fautesDImage(entree, octets) {
|
||
if (!estObjet(entree)) throw new TypeError('une entrée de manifeste est un objet');
|
||
const chemin = cheminImage(entree.fichier);
|
||
const image = estObjet(entree.image) ? entree.image : {};
|
||
const fautes = [];
|
||
if (sha256(octets) !== image.sha256) fautes.push({ chemin, faute: 'image remplacée : son empreinte diffère de celle du manifeste' });
|
||
let lue;
|
||
try {
|
||
lue = lireEntetePng(octets);
|
||
} catch (erreur) {
|
||
fautes.push({ chemin, faute: `illisible : ${erreur.message}` });
|
||
return fautes;
|
||
}
|
||
const facteur = entree.facteurEchelle;
|
||
const largeur = Math.round(entree.cadre?.largeur * facteur);
|
||
const hauteur = Math.round(entree.cadre?.hauteur * facteur);
|
||
if (lue.largeur !== largeur || lue.hauteur !== hauteur) {
|
||
fautes.push({ chemin, faute: `dimensions ${lue.largeur} × ${lue.hauteur}, attendues ${largeur} × ${hauteur} (cadre × facteur)` });
|
||
}
|
||
if (lue.largeur !== image.largeur || lue.hauteur !== image.hauteur) {
|
||
fautes.push({ chemin, faute: `dimensions ${lue.largeur} × ${lue.hauteur}, le manifeste inscrit ${image.largeur} × ${image.hauteur}` });
|
||
}
|
||
return fautes;
|
||
}
|
||
|
||
// --- Chemins hors de la racine neutre --------------------------------------------------
|
||
|
||
// Un chemin absolu commence en début de texte ou après l'un de ces
|
||
// caractères. Le deux-points en est (« poste:/home/… ») : le « // » d'une
|
||
// adresse web reste écarté, car une barre suivie d'une barre n'ouvre pas un
|
||
// chemin POSIX et un chemin réseau ne suit pas un deux-points. Un chiffre,
|
||
// une lettre, un point ou une barre avant « / » font une fraction ou un
|
||
// chemin relatif.
|
||
const AVANT_CHEMIN = new Set([...' \t\n\r\f\v([{<"\'«“‘=,;:>', '\u{a0}', '\u{202f}']);
|
||
// Le schéma d'une adresse de fichier, comparé sans casse.
|
||
const SCHEMA_FICHIER = 'file://';
|
||
// Un chemin s'arrête à un blanc ou à l'un de ces caractères…
|
||
const FIN_CHEMIN = new Set([...'"\'<>|«»“”‘', '\u{a0}', '\u{202f}']);
|
||
// … et perd la ponctuation qui le suit dans la phrase.
|
||
const PONCTUATION_FINALE = new Set([...'.,;:!?)]}»’”']);
|
||
|
||
const estBlanc = (caractere) => /\s/u.test(caractere);
|
||
const estLettre = (caractere) => /^[A-Za-z]$/.test(caractere);
|
||
|
||
// La forme d'un chemin absolu qui commence à la position rang : 'windows'
|
||
// (lecteur, chemin réseau sous l'une ou l'autre barre), 'posix' (racine,
|
||
// dossier personnel abrégé), 'fichier' (adresse file://, quoi qui la
|
||
// précède), ou null.
|
||
function formeEn(texte, rang) {
|
||
if (texte.slice(rang, rang + SCHEMA_FICHIER.length).toLowerCase() === SCHEMA_FICHIER) return 'fichier';
|
||
const avant = rang === 0 ? null : texte[rang - 1];
|
||
const borne = avant === null || AVANT_CHEMIN.has(avant) || estBlanc(avant);
|
||
const c = texte[rang];
|
||
const suivant = texte[rang + 1];
|
||
if (estLettre(c) && suivant === ':' && (texte[rang + 2] === '\\' || texte[rang + 2] === '/')) {
|
||
return borne ? 'windows' : null;
|
||
}
|
||
if (!borne) return null;
|
||
const troisieme = texte[rang + 2];
|
||
const ouvreReseau = (barre) => c === barre && suivant === barre && troisieme !== undefined && troisieme !== barre && !estBlanc(troisieme);
|
||
if (ouvreReseau('\\') || (avant !== ':' && ouvreReseau('/'))) return 'windows';
|
||
if (c === '/' && suivant !== undefined && suivant !== '/' && !estBlanc(suivant)) return 'posix';
|
||
if (c === '~' && /^~[A-Za-z0-9._-]*\//.test(texte.slice(rang, rang + 64))) return 'posix';
|
||
return null;
|
||
}
|
||
|
||
function finDuJeton(texte, debut) {
|
||
let fin = debut;
|
||
while (fin < texte.length && !estBlanc(texte[fin]) && !FIN_CHEMIN.has(texte[fin])) fin += 1;
|
||
while (fin > debut && PONCTUATION_FINALE.has(texte[fin - 1])) fin -= 1;
|
||
return fin;
|
||
}
|
||
|
||
// Le chemin que porte une adresse file:// et sa forme : « file:///C:/… »
|
||
// donne un lecteur, « file:///… » un chemin POSIX, « file://hôte/… » un
|
||
// chemin réseau. Les séquences %XX sont décodées ; une séquence invalide
|
||
// laisse le chemin tel qu'écrit.
|
||
function cheminDeFichier(adresse) {
|
||
const reste = adresse.slice(SCHEMA_FICHIER.length - 2);
|
||
let chemin = reste.startsWith('///') ? reste.slice(2) : reste;
|
||
try {
|
||
chemin = decodeURIComponent(chemin);
|
||
} catch {
|
||
// le chemin reste encodé, et se compare tel quel
|
||
}
|
||
if (/^\/[A-Za-z]:[\\/]/.test(chemin)) return { forme: 'windows', chemin: chemin.slice(1) };
|
||
if (chemin.startsWith('//')) return { forme: 'windows', chemin };
|
||
return { forme: 'posix', chemin };
|
||
}
|
||
|
||
const normaliserWindows = (chemin) => chemin.replaceAll('/', '\\').toLowerCase();
|
||
const estSeparateur = (caractere) => caractere === '/' || caractere === '\\';
|
||
|
||
function formeDeRacine(racine) {
|
||
if (typeof racine !== 'string' || racine.length === 0) throw new TypeError('la racine neutre est un chemin absolu non vide');
|
||
if (/^[A-Za-z]:[\\/]/.test(racine) || racine.startsWith('\\\\')) return 'windows';
|
||
if (racine.startsWith('/')) return 'posix';
|
||
throw new TypeError(`la racine neutre n’est pas un chemin absolu : « ${racine} »`);
|
||
}
|
||
|
||
// La racine sans ses séparateurs finaux, sauf celui d'une racine de lecteur
|
||
// ou de système (« C:\ », « / »).
|
||
function sansSeparateurFinal(racine) {
|
||
let fin = racine.length;
|
||
while (fin > 1 && estSeparateur(racine[fin - 1]) && !/^[A-Za-z]:$/.test(racine.slice(0, fin - 1))) fin -= 1;
|
||
return racine.slice(0, fin);
|
||
}
|
||
|
||
/**
|
||
* Les chemins absolus d'un texte qui ne sont ni la racine neutre ni sous
|
||
* elle (§ 19.4) : chemins de lecteur et réseau de Windows, chemins POSIX,
|
||
* dossier personnel abrégé « ~/ », adresses file://. Une adresse se juge
|
||
* sur le chemin décodé qu'elle porte et se relève telle qu'écrite. Sous
|
||
* Windows, la comparaison ignore la casse et le sens des barres. La racine
|
||
* se reconnaît entière même quand elle porte une espace ; un autre chemin
|
||
* s'arrête au premier blanc, ce qui suffit à relever la part qui nomme un
|
||
* compte. Chaque chemin relevé une fois, dans l'ordre du texte.
|
||
* @param {string} texte
|
||
* @param {string} racine la racine neutre, absolue
|
||
* @returns {string[]}
|
||
*/
|
||
export function cheminsHorsRacine(texte, racine) {
|
||
if (typeof texte !== 'string') throw new TypeError('le texte relevé est une chaîne');
|
||
const formeRacine = formeDeRacine(racine);
|
||
const base = sansSeparateurFinal(racine);
|
||
const commeRacine = formeRacine === 'windows' ? normaliserWindows : (chemin) => chemin;
|
||
const baseComparee = commeRacine(base);
|
||
const sousLaRacine = (chemin) => {
|
||
if (commeRacine(chemin.slice(0, base.length)) !== baseComparee) return false;
|
||
const reste = chemin.slice(base.length);
|
||
return reste === '' || estSeparateur(reste[0]) || estSeparateur(base.at(-1));
|
||
};
|
||
const releves = [];
|
||
let rang = 0;
|
||
while (rang < texte.length) {
|
||
const forme = formeEn(texte, rang);
|
||
if (forme === null) {
|
||
rang += 1;
|
||
continue;
|
||
}
|
||
let fin;
|
||
let dessous;
|
||
if (forme === 'fichier') {
|
||
fin = finDuJeton(texte, rang);
|
||
const porte = cheminDeFichier(texte.slice(rang, fin));
|
||
dessous = porte.forme === formeRacine && sousLaRacine(porte.chemin);
|
||
} else {
|
||
// La racine se reconnaît avant la coupe au blanc, qu'elle peut porter.
|
||
const aLaRacine = forme === formeRacine && commeRacine(texte.slice(rang, rang + base.length)) === baseComparee;
|
||
fin = finDuJeton(texte, aLaRacine ? rang + base.length : rang);
|
||
dessous = aLaRacine && sousLaRacine(texte.slice(rang, fin));
|
||
}
|
||
if (!dessous) {
|
||
const chemin = texte.slice(rang, fin);
|
||
if (!releves.includes(chemin)) releves.push(chemin);
|
||
}
|
||
rang = Math.max(fin, rang + 1);
|
||
}
|
||
return releves;
|
||
}
|
||
|
||
// --- Un relevé ne nomme pas sa machine (contrainte 27) ---------------------------------
|
||
|
||
const CLES_MANIFESTE = ['captures', 'format', 'pilote', 'reportees'];
|
||
const CLES_PILOTE = ['cadre', 'facteurEchelle', 'mode', 'navigateur', 'versionNavigateur'];
|
||
const CLES_CADRE = ['hauteur', 'largeur'];
|
||
const CLES_CAPTURE = [
|
||
'cadre', 'empreinte', 'etape', 'etat', 'facteurEchelle', 'fichier', 'graine', 'image',
|
||
'instant', 'mode', 'pdf', 'source', 'styles', 'texte', 'theme', 'versionReelle',
|
||
];
|
||
const CLES_IMAGE = ['hauteur', 'largeur', 'sha256'];
|
||
const CLES_REPORTEE = ['etape', 'fichier', 'raison'];
|
||
|
||
function clesHorsContrat(objet, permises, prefixe) {
|
||
if (!estObjet(objet)) return [];
|
||
return Object.keys(objet)
|
||
.filter((cle) => !permises.includes(cle))
|
||
.sort(comparer)
|
||
.map((cle) => `clé hors du contrat : « ${prefixe}${cle} »`);
|
||
}
|
||
|
||
function chainesDe(valeur) {
|
||
if (typeof valeur === 'string') return [valeur];
|
||
if (Array.isArray(valeur)) return valeur.flatMap(chainesDe);
|
||
if (estObjet(valeur)) return Object.keys(valeur).sort(comparer).flatMap((cle) => [cle, ...chainesDe(valeur[cle])]);
|
||
return [];
|
||
}
|
||
|
||
function cheminsDe(valeur, racine) {
|
||
const releves = chainesDe(valeur).flatMap((chaine) => cheminsHorsRacine(chaine, racine));
|
||
return [...new Set(releves)].map((chemin) => `chemin hors de la racine neutre : « ${chemin} »`);
|
||
}
|
||
|
||
/**
|
||
* La garde de la contrainte 27 : un manifeste ne nomme ni son hôte, ni un
|
||
* compte, ni un utilisateur, ni une date de la machine. Les clés du
|
||
* manifeste, du pilote, de chaque capture, de son cadre et de son image, et
|
||
* de chaque reportée forment des listes fermées, celles du contrat : un
|
||
* champ « hote » ou « date » est une clé hors du contrat. Toute chaîne, clés
|
||
* comprises, est passée à cheminsHorsRacine.
|
||
* @param {object} manifeste
|
||
* @param {string} racine la racine neutre des captures
|
||
* @returns {Faute[]} le manifeste et son pilote, puis chaque capture et chaque reportée, dans leur ordre
|
||
*/
|
||
export function fautesDeMachine(manifeste, racine) {
|
||
const captures = capturesDe(manifeste);
|
||
const reportees = manifeste.reportees ?? [];
|
||
exigerTableau(reportees, 'manifeste.reportees');
|
||
const fautes = [];
|
||
const ajouter = (chemin, natures) => {
|
||
for (const faute of natures) fautes.push({ chemin, faute });
|
||
};
|
||
const { captures: _captures, reportees: _reportees, ...tete } = manifeste;
|
||
ajouter(CHEMIN_MANIFESTE, [
|
||
...clesHorsContrat(manifeste, CLES_MANIFESTE, ''),
|
||
...clesHorsContrat(manifeste.pilote, CLES_PILOTE, 'pilote.'),
|
||
...clesHorsContrat(manifeste.pilote?.cadre, CLES_CADRE, 'pilote.cadre.'),
|
||
...cheminsDe(tete, racine),
|
||
]);
|
||
for (const entree of captures) {
|
||
ajouter(cheminImage(entree?.fichier), [
|
||
...clesHorsContrat(entree, CLES_CAPTURE, ''),
|
||
...clesHorsContrat(entree?.cadre, CLES_CADRE, 'cadre.'),
|
||
...clesHorsContrat(entree?.image, CLES_IMAGE, 'image.'),
|
||
...cheminsDe(entree, racine),
|
||
]);
|
||
}
|
||
for (const entree of reportees) {
|
||
ajouter(cheminImage(entree?.fichier), [...clesHorsContrat(entree, CLES_REPORTEE, ''), ...cheminsDe(entree, racine)]);
|
||
}
|
||
return fautes;
|
||
}
|