The walkthrough produces the retained plan's badge sheet through the Badges section and rasterises the PDF under node with pdfjs at 96 dpi: page 1 becomes the control page, page 2 the first sheet of four A6. Manifest entries carry the PDF's sha256, so an unchanged PDF leaves the images unchanged; two productions under the driven clock must hash the same, and a missing canvas fails the step by name with nothing written. No capture remains pending. Checked: node 3787, browser 758, node-long 101 from the index alone. --- FR --- [ADD] captures : feuille de contrôle et planche, tramées du PDF Le parcours produit la planche du retenu par la section Badges et trame le PDF sous node par pdfjs à 96 ppp : la page 1 donne la feuille de contrôle, la page 2 la première planche de quatre A6. Les entrées du manifeste portent le sha256 du PDF : un PDF inchangé laisse les images inchangées ; deux productions sous l'horloge pilotée ont même empreinte, et un canevas absent fait échouer l'étape en le nommant, rien d'écrit. Plus aucune capture n'est à venir. Vérifié : node 3787, navigateur 758, longues 101 depuis l'index seul. Assisted-by: Claude Opus 5.5
582 lines
27 KiB
JavaScript
582 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.
|
||
// Vide : le parcours photographie chaque capture déclarée.
|
||
export const A_VENIR = Object.freeze([]);
|
||
|
||
// --- 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;
|
||
}
|