// © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Les commandes des captures d'écran, côté node (§ 19.4, § 19.5, § 19.7) : // vitest.config.js les déclare dans browser.commands, et la page les appelle // par commands de vitest/browser. Chacune reçoit d'abord le contexte de la // commande — la page Playwright, le cadre de l'épreuve —, puis l'unique // objet que la page lui passe ; elle rend du JSON. Aucun octet ne traverse // en Uint8Array : le RPC des commandes sérialiserait un tableau typé en objet // {"0": …}. La capture d'élément se prend ici, sur le cadre de l'épreuve, et // s'écrit sur le disque sans repasser par la page. // // Les fichiers vivent sous une racine : celle du projet (racine null), ou // un dossier d'épreuve que dossierEpreuve a créé, et que seul // effacerDossierEpreuve retire. Toute autre racine est refusée. // // Le mode d'écriture. Chaque étape photographiée dépose, sous // doc/captures/.attente/, son image .png et son entrée complète // .json. publierCaptures publie d'un bloc : toutes les captures // déclarées déposées, ni plus ni moins, chaque entrée relue et ses // dimensions contrôlées, le manifeste assemblé et ses contrôles d'ordre et // de machine tenus, alors seulement les images et le manifeste s'écrivent. // Une image ne se réécrit que si l'empreinte de son étape a changé, ou si // le fichier présent n'est plus celui que le manifeste décrit ; le // manifeste ne se réécrit que si ses octets changent ; une image qui n'est // plus déclarée part. Le dossier d'attente est jeté à la fin de chaque // publication, réussie ou refusée : une publication refusée laisse le // manifeste et les images tels qu'avant. // // Le mode de vérification compare l'entrée d'une étape à celle du // manifeste (comparerCapture) et n'écrit rien. // // Les images de la planche (§ 19.6) ne viennent pas du navigateur : la page // passe le PDF produit, en base64, à tramerPdf, qui lit chaque page demandée // par pdfjs-dist — sa couche de texte et ses traits (test/pdf.js), sa taille // à 96 ppp — et, en écriture, la trame en PNG sous le dossier d'attente, par // le canevas natif que pdfjs-dist charge lui-même, @napi-rs/canvas, sa // dépendance optionnelle. Seul make captures exige ce canevas : la lecture // s'en passe, et le tramage refuse en le nommant quand il manque. pdfjs-dist // et test/pdf.js se chargent au premier appel : la configuration du lanceur // importe ce module sans eux. La publication tire son pilote des seules // captures du navigateur : une page de PDF a la taille de sa page. // // Le manifeste ne nomme pas sa machine (§ 19.5, contrainte 27). Outre // fautesDeMachine — clés fermées, chemins hors de la racine neutre —, la // publication refuse toute chaîne qui porte, comme mot entier, le nom // d'hôte de la machine, son premier libellé ou son utilisateur, lus de // node:os — sauf un nom que les sources fixes du scénario portent déjà, un // prénom de la démonstration par exemple, qu'aucun texte ne distingue de la // machine —, et tout instant, celui de la capture ou un instant écrit dans // un texte, que l'horloge pilotée du montage n'atteint pas : une lecture de // l'horloge du système tombe hors de son intervalle. import { createHash } from 'node:crypto'; import { mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { hostname, tmpdir, userInfo } from 'node:os'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { A_VENIR, CAPTURES, CHEMIN_MANIFESTE, DOSSIER_CAPTURES, REPORTEES, canoniser, cheminImage, cheminsHorsRacine as cheminsHors, empreinteEtape, fautesDImage, fautesDeMachine, lireEntetePng, verifierOrdre, } from '../../scripts/documentation/manifeste.js'; import { creerHorlogePilotee } from '../../src/application/horloge.js'; import { libelle } from '../../src/application/libelles.js'; import { estHorodatage } from '../../src/stockage/journal.js'; import { MODE_AFFICHAGE, suivreRequetes } from '../chromium.js'; /** Les deux modes des captures : verifier compare au manifeste, ecrire * dépose puis publie (make captures). */ export const MODES = Object.freeze(['verifier', 'ecrire']); /** Les commandes que vitest.config.js déclare, toutes exportées ici. */ export const COMMANDES = Object.freeze([ 'requetesHorsBoucle', 'echecsHorsBoucle', 'declarationsCaptures', 'cheminsHorsRacine', 'lireManifeste', 'prendreCliche', 'deposerCapture', 'comparerCapture', 'publierCaptures', 'viderAttente', 'dossierEpreuve', 'effacerDossierEpreuve', 'empreintesDossier', 'copierDansEpreuve', 'tramerPdf', ]); /** * Le mode des captures que GTT_CAPTURES demande : 'verifier' quand la * variable est absente ou vide. * * @param {Record} env * @returns {'verifier'|'ecrire'} * @throws {Error} une autre valeur, nommée */ export function modeCaptures(env) { const valeur = env.GTT_CAPTURES ?? ''; if (valeur === '') return 'verifier'; if (MODES.includes(valeur)) return valeur; throw new Error(`GTT_CAPTURES : « ${valeur} » n’est ni ${MODES.map((m) => `« ${m} »`).join(' ni ')}`); } // La racine du projet, deux dossiers au-dessus de ce module. const PROJET = fileURLToPath(new URL('../..', import.meta.url)); // Le dossier d'attente, relatif à une racine. const ATTENTE = `${DOSSIER_CAPTURES}/.attente`; // Les dossiers d'épreuve créés par dossierEpreuve et pas encore effacés. const DOSSIERS_EPREUVE = new Set(); // Un nom de capture : lettres, chiffres, tirets, sans point ni barre. const NOM_CAPTURE = /^[A-Za-z0-9][A-Za-z0-9-]*$/; const sha256 = (octets) => createHash('sha256').update(octets).digest('hex'); // La racine absolue que désigne racine : le projet pour null, un dossier // d'épreuve vivant, sinon un refus. function racineDe(racine) { if (racine === null || racine === undefined) return PROJET; if (DOSSIERS_EPREUVE.has(racine)) return racine; throw new Error(`captures : racine refusée « ${racine} » — ni le projet, ni un dossier d’épreuve vivant`); } function exigerNom(fichier) { if (typeof fichier !== 'string' || !NOM_CAPTURE.test(fichier)) { throw new Error(`captures : nom de capture refusé ${JSON.stringify(fichier)}`); } } // Le contenu d'un fichier, null quand il n'existe pas. async function lireSiPresent(chemin, encodage) { try { return await readFile(chemin, encodage); } catch (erreur) { if (erreur.code === 'ENOENT') return null; throw erreur; } } // Écrit un fichier par un fichier voisin renommé : un lecteur ne voit jamais // un fichier à moitié écrit. async function ecrireAtomique(chemin, contenu) { const provisoire = `${chemin}.ecriture`; await writeFile(provisoire, contenu); await rename(provisoire, chemin); } // Le manifeste d'une racine absolue, analysé ; null quand il n'existe pas. async function manifesteDe(base) { const texte = await lireSiPresent(join(base, CHEMIN_MANIFESTE), 'utf8'); if (texte === null) return null; try { return JSON.parse(texte); } catch (erreur) { throw new Error(`${CHEMIN_MANIFESTE} illisible : ${erreur.message}`); } } /** Les requêtes hors de la boucle locale que la page a émises depuis * l'appel précédent (test/chromium.js) ; le premier appel pose le suivi. */ export async function requetesHorsBoucle(context) { return suivreRequetes(context.page).relever(); } /** Les échecs des requêtes hors de la boucle locale, { adresse, cause }, * depuis l'appel précédent : la cause dit qui a refusé la connexion. */ export async function echecsHorsBoucle(context) { return suivreRequetes(context.page).releverEchecs(); } /** Les captures déclarées (CAPTURES) et celles qui sont à venir (A_VENIR) : * la page ne charge pas scripts/documentation/manifeste.js, qui est de node. */ export async function declarationsCaptures() { return { captures: CAPTURES.map((capture) => ({ ...capture })), aVenir: [...A_VENIR] }; } /** Les chemins hors de la racine neutre que porte le texte relevé : * cheminsHorsRacine de scripts/documentation/manifeste.js, sur les textes * joints ligne à ligne. */ export async function cheminsHorsRacine(_context, { texte, racine }) { if (!Array.isArray(texte) || texte.some((ligne) => typeof ligne !== 'string')) { throw new TypeError('cheminsHorsRacine : texte est une liste de chaînes'); } return cheminsHors(texte.join('\n'), racine); } /** Le manifeste de la racine, analysé ; null quand il n'existe pas. */ export async function lireManifeste(_context, { racine = null } = {}) { return manifesteDe(racineDe(racine)); } /** * Photographie l'élément que désigne selecteur dans le cadre de l'épreuve * et écrit l'image sous le dossier d'attente de la racine, à * .png. Le sélecteur doit désigner un seul élément. * * @returns {Promise<{chemin: string}>} le chemin absolu de l'image */ export async function prendreCliche(context, { fichier, selecteur, racine = null }) { exigerNom(fichier); const base = racineDe(racine); const element = context.iframe.locator(selecteur); const nombre = await element.count(); if (nombre !== 1) throw new Error(`capture ${fichier} : le sélecteur « ${selecteur} » désigne ${nombre} éléments, un attendu`); const octets = await element.screenshot({ animations: 'disabled', caret: 'hide' }); const dossier = join(base, ATTENTE); await mkdir(dossier, { recursive: true }); const chemin = join(dossier, `${fichier}.png`); await ecrireAtomique(chemin, octets); return { chemin }; } /** * Dépose l'entrée d'une étape à côté de son image, sous le dossier * d'attente : l'image que prendreCliche a écrite à chemin, relue ; l'entrée * complétée de image {sha256, largeur, hauteur} et de son empreinte * d'étape. Des dimensions autres que cadre × facteur d'échelle refusent le * dépôt. * * @returns {Promise<{empreinte: string}>} */ export async function deposerCapture(_context, { fichier, entree, chemin, racine = null }) { exigerNom(fichier); const base = racineDe(racine); const attendu = join(base, ATTENTE, `${fichier}.png`); if (typeof chemin !== 'string' || resolve(chemin) !== attendu) { throw new Error(`capture ${fichier} : image attendue à ${relative(base, attendu)}, reçue ${JSON.stringify(chemin)}`); } if (entree?.fichier !== fichier) throw new Error(`capture ${fichier} : l’entrée porte le nom ${JSON.stringify(entree?.fichier)}`); const octets = new Uint8Array(await readFile(attendu)); const { largeur, hauteur } = lireEntetePng(octets); const complete = { ...entree, image: { sha256: sha256(octets), largeur, hauteur } }; const fautes = fautesDImage(complete, octets); if (fautes.length > 0) throw new Error(`capture ${fichier} : ${fautes.map(({ faute }) => faute).join(' ; ')}`); complete.empreinte = empreinteEtape(complete); await ecrireAtomique(join(base, ATTENTE, `${fichier}.json`), JSON.stringify(complete)); return { empreinte: complete.empreinte }; } // Le texte JSON canonique d'une valeur, pour comparer deux champs. const canonique = (valeur) => canoniser({ valeur }); // Le premier rang où deux listes de textes diffèrent, et les deux textes. function premierEcart(avant, apres) { const longueur = Math.max(avant.length, apres.length); for (let rang = 0; rang < longueur; rang += 1) { if (avant[rang] !== apres[rang]) return { rang, avant: avant[rang] ?? null, apres: apres[rang] ?? null }; } return null; } // Un texte relevé en citation, ou « (rien) » au-delà de la fin. const citer = (texte) => (texte === null ? '(rien)' : `« ${texte} »`); /** * Compare l'entrée d'une étape à celle du manifeste de la racine (§ 19.7) : * l'empreinte d'étape d'abord ; quand elle diffère, chaque champ qui * diffère est nommé, le texte avec son premier écart. Rien ne s'écrit. * * @returns {Promise<{ecarts: string[]}>} vide quand l'empreinte est la même */ export async function comparerCapture(_context, { fichier, entree, racine = null }) { exigerNom(fichier); const manifeste = await manifesteDe(racineDe(racine)); const nom = `${fichier} (étape ${entree?.etape})`; if (manifeste === null) return { ecarts: [`${nom} : aucun manifeste, ${CHEMIN_MANIFESTE} — lancer make captures`] }; const precedente = (manifeste.captures ?? []).find((capture) => capture?.fichier === fichier); if (precedente === undefined) return { ecarts: [`${nom} : absente du manifeste — lancer make captures`] }; if (empreinteEtape(entree) === precedente.empreinte) return { ecarts: [] }; const ecarts = []; const exclus = new Set(['image', 'empreinte', 'versionReelle']); const champs = [...new Set([...Object.keys(entree), ...Object.keys(precedente)])].filter((champ) => !exclus.has(champ)).sort(); for (const champ of champs) { if (canonique(entree[champ] ?? null) === canonique(precedente[champ] ?? null)) continue; if (champ === 'texte' && Array.isArray(entree.texte) && Array.isArray(precedente.texte)) { const ecart = premierEcart(precedente.texte, entree.texte); ecarts.push(`${nom} : écart de texte, rang ${ecart.rang} : ${citer(ecart.apres)} au lieu de ${citer(ecart.avant)}`); } else { ecarts.push(`${nom} : écart de ${champ}`); } } if (ecarts.length === 0) ecarts.push(`${nom} : empreinte d’étape différente de celle du manifeste`); return { ecarts }; } // Les captures déposées sous le dossier d'attente : { fichier → {png, json} }. // Un fichier d'une autre forme lève. async function deposees(attente) { let noms; try { noms = await readdir(attente); } catch (erreur) { if (erreur.code === 'ENOENT') return new Map(); throw erreur; } const depots = new Map(); for (const nom of noms.sort()) { const forme = /^(.+)\.(png|json)$/.exec(nom); if (forme === null || !NOM_CAPTURE.test(forme[1])) throw new Error(`publication : fichier inattendu dans ${ATTENTE} : ${nom}`); const [, fichier, extension] = forme; depots.set(fichier, { ...depots.get(fichier), [extension]: join(attente, nom) }); } return depots; } /** * L'identité de la machine que la publication refuse de publier : son nom * d'hôte, et son utilisateur, null quand le système n'en connaît aucun. * * @returns {{hote: string, utilisateur: string|null}} */ export function machineCourante() { let utilisateur = null; try { utilisateur = userInfo().username; } catch { // Un processus sans entrée dans la base des comptes : aucun nom à taire. } return { hote: hostname(), utilisateur }; } /** La portée de l'horloge pilotée qu'admet un instant de capture, en ms * depuis son départ : le parcours l'avance de quelques minutes ; une * lecture de l'horloge du système tombe des jours plus loin. */ export const PORTEE_PILOTEE_MS = 24 * 60 * 60 * 1000; /** Le premier et le dernier horodatage qu'admet un instant de capture : * ceux de creerHorlogePilotee à son départ, puis avancée de * PORTEE_PILOTEE_MS. Les deux portent le décalage du départ. */ export const INSTANTS_PILOTES = (() => { const horloge = creerHorlogePilotee(); const debut = horloge.horodatage(); horloge.avancer(PORTEE_PILOTEE_MS); return Object.freeze({ debut, fin: horloge.horodatage() }); })(); // Vrai quand l'instant est un horodatage que l'horloge pilotée atteint : // sa forme, le décalage du départ, entre début et fin. À forme et décalage // égaux, l'ordre des textes est celui des instants. function instantPilote(instant) { const { debut, fin } = INSTANTS_PILOTES; return ( typeof instant === 'string' && estHorodatage(instant) && instant.slice(19) === debut.slice(19) && instant >= debut && instant <= fin ); } // Un instant écrit dans un texte, sous les formes de l'application : un // horodatage ISO, millisecondes et « Z » compris, que seul instantPilote // admet ; en lettres, la date de format.date suivie, après « , » ou « à », // de l'heure de format.heure (format.horodatage, dater) ou de HH:MM:SS // (planche.pied.horodatage). const INSTANT_ISO = /(? String(n).padStart(2, '0'); // La clé AAAA-MM-JJTHH:MM[:SS] d'un instant en lettres, lu par les textes // mêmes qui l'écrivent : le mois est celui dont format.date redonne la // date telle quelle, l'heure celle que format.heure redonne telle quelle. // null quand le texte n'est pas une forme de l'application. function cleEnLettres([, jourEcrit, nomDuMois, anneeEcrite, heureH, minuteH, heureS, minuteS, seconde]) { const annee = Number(anneeEcrite); const jour = jourEcrit === '1er' ? 1 : Number(jourEcrit); const dateEcrite = `${jourEcrit} ${nomDuMois} ${anneeEcrite}`; const mois = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].find((m) => libelle('format.date', { annee, mois: m, jour }) === dateEcrite); if (mois === undefined) return null; const jourCle = `${anneeEcrite}-${deuxChiffres(mois)}-${deuxChiffres(jour)}`; if (heureS !== undefined) return `${jourCle}T${heureS}:${minuteS}:${seconde}`; const [heure, minute] = [Number(heureH), Number(minuteH)]; if (libelle('format.heure', { heure, minute }) !== `${heureH} h ${minuteH}`) return null; return `${jourCle}T${deuxChiffres(heure)}:${minuteH}`; } // Le premier instant d'un texte que l'horloge pilotée n'atteint pas, tel // qu'il s'écrit, ou null. Un instant en lettres se lit à l'heure locale du // départ, à la minute ou à la seconde qu'il porte : sa clé se compare au // même préfixe de début et de fin. function instantEtranger(texte) { for (const [ecrit] of texte.matchAll(INSTANT_ISO)) if (!instantPilote(ecrit)) return ecrit; const { debut, fin } = INSTANTS_PILOTES; for (const morceaux of texte.matchAll(INSTANT_EN_LETTRES)) { const cle = cleEnLettres(morceaux.map((m) => m?.replace(/\s/gu, ' '))); if (cle !== null && (cle < debut.slice(0, cle.length) || cle > fin.slice(0, cle.length))) return morceaux[0]; } return null; } const echapper = (texte) => texte.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // Le motif d'un nom pris comme mot entier, sans égard à la casse : ni // lettre, ni chiffre, ni « _ » ne le touche. const motEntier = (nom) => new RegExp(`(? (a < b ? -1 : a > b ? 1 : 0)) .flatMap((cle) => [cle, ...chaines(valeur[cle])]); } return []; } // Les sources fixes dont le scénario des captures tire ses textes, sous le // projet : les réservoirs et le catalogue de la démonstration, les // démonstrations livrées, les CSV d'exemple, les tables de libellés, et le // parcours, qui tape les siens. Un dossier se lit fichier par fichier, sans // ses sous-dossiers ni ses épreuves. export const SOURCES_DU_SCENARIO = Object.freeze([ 'src/demo', 'src/demo/livrees', 'exemples', 'src/application/libelles', 'src/interface/libelles', 'src/interface/parcours.navigateur.test.js', ]); // Le texte d'un fichier : UTF-8 quand ses octets le sont, windows-1252 // sinon, l'encodage d'un CSV d'exemple. function decoderSource(octets) { try { return new TextDecoder('utf-8', { fatal: true }).decode(octets); } catch { return new TextDecoder('windows-1252').decode(octets); } } /** * Les textes des SOURCES_DU_SCENARIO, lus sous le projet, dossier par * dossier dans l'ordre de la liste, chaque dossier dans l'ordre des points * de code de ses noms. * * @returns {Promise} */ export async function lireSourcesDuScenario() { const textes = []; for (const source of SOURCES_DU_SCENARIO) { const chemin = join(PROJET, source); let fichiers = [chemin]; if (!source.endsWith('.js')) { const entrees = await readdir(chemin, { withFileTypes: true }); fichiers = entrees .filter((entree) => entree.isFile() && !entree.name.includes('.test.')) .map(({ name }) => name) .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) .map((nom) => join(chemin, nom)); } for (const fichier of fichiers) textes.push(decoderSource(await readFile(fichier))); } return textes; } /** * Les fautes d'identité d'un manifeste (§ 19.5, contrainte 27) : une * chaîne, clés comprises, qui porte comme mot entier le nom d'hôte de la * machine, le premier libellé de ce nom ou son utilisateur ; l'instant * d'une capture que l'horloge pilotée n'atteint pas (INSTANTS_PILOTES) ; * un instant écrit dans une chaîne qu'elle n'atteint pas (instantEtranger). * Un nom que l'une des sources porte déjà comme mot entier ne se cherche * pas : le texte qui le porte vient du scénario, et le chercher refuserait * toute machine nommée comme un prénom de la démonstration. Le pilote * d'abord, puis chaque capture et chaque reportée, dans leur ordre ; une * faute par sorte de nom — hôte, utilisateur — et par entrée, qui ne cite * pas le nom ; une par entrée pour les instants de ses chaînes, qui cite le * premier. Un nom vide ou null ne se cherche pas. * * @param {Object} manifeste * @param {{hote: string|null, utilisateur: string|null}} machine * @param {{sources?: string[]}} [options] les textes des sources du * scénario (lireSourcesDuScenario), aucun par défaut * @returns {Array<{chemin: string, faute: string}>} */ export function fautesDIdentite(manifeste, machine, { sources = [] } = {}) { const noms = []; const hote = machine?.hote ?? ''; if (hote !== '') { noms.push(['nom d’hôte de la machine', hote]); const court = hote.split('.')[0]; if (court !== '' && court !== hote) noms.push(['nom d’hôte de la machine', court]); } if ((machine?.utilisateur ?? '') !== '') noms.push(['utilisateur de la machine', machine.utilisateur]); const motifs = noms .map(([quoi, nom]) => [quoi, motEntier(nom)]) .filter(([, motif]) => !sources.some((source) => motif.test(source))); const { debut, fin } = INSTANTS_PILOTES; const fautes = []; // Les noms, puis les instants écrits dans les chaînes, hors de la clé // instant d'une capture, contrôlée à part. const lire = (chemin, valeur, instantAPart = false) => { const textes = chaines(valeur); const vus = new Set(); for (const [quoi, motif] of motifs) { if (vus.has(quoi) || !textes.some((texte) => motif.test(texte))) continue; vus.add(quoi); fautes.push({ chemin, faute: quoi }); } for (const texte of instantAPart ? chaines({ ...valeur, instant: null }) : textes) { const ecrit = instantEtranger(texte); if (ecrit === null) continue; fautes.push({ chemin, faute: `instant « ${ecrit} » dans un texte, hors de l’horloge pilotée, de « ${debut} » à « ${fin} »` }); break; } }; lire(CHEMIN_MANIFESTE, manifeste.pilote ?? null); for (const entree of manifeste.captures ?? []) { lire(cheminImage(entree?.fichier), entree, true); if (!instantPilote(entree?.instant)) { const instant = typeof entree?.instant === 'string' ? `« ${entree.instant} »` : JSON.stringify(entree?.instant ?? null); fautes.push({ chemin: cheminImage(entree?.fichier), faute: `instant ${instant} hors de l’horloge pilotée, de « ${debut} » à « ${fin} »` }); } } for (const entree of manifeste.reportees ?? []) lire(cheminImage(entree?.fichier), entree); return fautes; } // Le pilote du manifeste : le navigateur et sa version, le mode nommé, le // facteur d'échelle et le cadre, communs à toutes les captures du // navigateur. Une page de PDF n'en relève pas : son cadre est sa page à // PPP_TRAMAGE (§ 19.6). Sans capture du navigateur, aucun pilote : refus. function piloteDe(context, captures) { const duNavigateur = captures.filter(({ source }) => source !== 'pdf'); const [premiere] = duNavigateur; if (premiere === undefined) throw new Error('publication : aucune capture du navigateur — le manifeste n’aurait aucun pilote'); for (const capture of duNavigateur) { if (capture.facteurEchelle !== premiere.facteurEchelle || canonique(capture.cadre) !== canonique(premiere.cadre)) { throw new Error(`publication : ${capture.fichier} n’a pas le cadre ni le facteur d’échelle de ${premiere.fichier}`); } } const versionNavigateur = context.page.context().browser()?.version() ?? 'inconnue'; return { navigateur: 'chromium', versionNavigateur, mode: MODE_AFFICHAGE, facteurEchelle: premiere.facteurEchelle, cadre: { largeur: premiere.cadre.largeur, hauteur: premiere.cadre.hauteur }, }; } // Assemble la publication sans rien écrire : le manifeste, les images à // écrire, les images à retirer. Lève sur la première raison de refuser. async function preparer(context, base, declarees, racineNeutre, machine, sources) { if (!Array.isArray(declarees) || declarees.length === 0) { throw new Error('publication : aucune capture déclarée — zéro capture n’est pas zéro capture périmée'); } const connues = new Map(CAPTURES.map((capture) => [capture.fichier, capture])); for (const fichier of declarees) { if (!connues.has(fichier)) throw new Error(`publication : ${JSON.stringify(fichier)} n’est pas déclarée dans CAPTURES`); } if (new Set(declarees).size !== declarees.length) throw new Error('publication : une capture déclarée deux fois'); const depots = await deposees(join(base, ATTENTE)); if (depots.size === 0) throw new Error('publication : aucune capture déposée — rien n’est publié'); const completes = [...depots].filter(([, depot]) => depot.png && depot.json).map(([fichier]) => fichier); const manquantes = declarees.filter((fichier) => !completes.includes(fichier)); if (manquantes.length > 0) { throw new Error(`publication : étapes sans capture déposée, rien n’est publié : ${manquantes.join(', ')}`); } const enTrop = [...depots.keys()].filter((fichier) => !declarees.includes(fichier)); if (enTrop.length > 0) throw new Error(`publication : captures déposées hors des déclarées : ${enTrop.join(', ')}`); const ancien = await manifesteDe(base); const anciennes = new Map((ancien?.captures ?? []).map((capture) => [capture?.fichier, capture])); const captures = []; const aEcrire = []; const gardees = []; for (const { fichier, etape } of CAPTURES.filter((capture) => declarees.includes(capture.fichier))) { const { png, json } = depots.get(fichier); const entree = JSON.parse(await readFile(json, 'utf8')); const octets = new Uint8Array(await readFile(png)); if (entree.fichier !== fichier || entree.etape !== etape) { throw new Error(`publication : ${fichier} déposée sous le nom ${entree.fichier}, étape ${entree.etape} ; étape ${etape} déclarée`); } if (entree.empreinte !== empreinteEtape(entree)) throw new Error(`publication : ${fichier} : l’empreinte déposée ne répond pas à l’entrée`); const fautes = fautesDImage(entree, octets); if (fautes.length > 0) throw new Error(`publication : ${fichier} : ${fautes.map(({ faute }) => faute).join(' ; ')}`); const precedente = anciennes.get(fichier); const presente = await lireSiPresent(join(base, cheminImage(fichier))); if (precedente?.empreinte === entree.empreinte && presente !== null && sha256(presente) === precedente.image?.sha256) { captures.push({ ...entree, image: precedente.image }); gardees.push(fichier); } else { captures.push(entree); aEcrire.push({ fichier, octets }); } } const manifeste = { format: 1, pilote: piloteDe(context, captures), captures, reportees: REPORTEES.map((r) => ({ ...r })) }; const aVenir = CAPTURES.map(({ fichier }) => fichier).filter((fichier) => !declarees.includes(fichier)); const fautes = [ ...verifierOrdre(manifeste, { aVenir }), ...fautesDeMachine(manifeste, racineNeutre), ...fautesDIdentite(manifeste, machine, { sources }), ]; if (fautes.length > 0) { throw new Error(`publication refusée :\n${fautes.map(({ chemin, faute }) => `${chemin} : ${faute}`).join('\n')}`); } let presentes; try { presentes = await readdir(join(base, DOSSIER_CAPTURES)); } catch (erreur) { if (erreur.code !== 'ENOENT') throw erreur; presentes = []; } const retirees = presentes .filter((nom) => nom.endsWith('.png')) .map((nom) => nom.slice(0, -'.png'.length)) .filter((fichier) => !declarees.includes(fichier)) .sort(); return { texte: canoniser(manifeste), aEcrire, gardees, retirees }; } /** * Publie d'un bloc les captures déposées (§ 19.5) : declarees, les noms de * CAPTURES que cette publication porte — les autres comptent comme à venir * au contrôle d'ordre ; racineNeutre, la racine de travail que les textes * relevés peuvent nommer (fautesDeMachine). Le troisième argument, que la * page ne passe jamais, remplace l'identité de la machine que * fautesDIdentite cherche, machineCourante() par défaut ; les noms que * lireSourcesDuScenario porte déjà ne s'y cherchent pas. Le dossier * d'attente est jeté, que la publication réussisse ou non. * * @returns {Promise<{ecrites: string[], gardees: string[], retirees: string[], manifeste: boolean}>} * manifeste : vrai quand ses octets ont changé */ export async function publierCaptures(context, { declarees, racine = null, racineNeutre } = {}, { machine = machineCourante() } = {}) { const base = racineDe(racine); try { const sources = await lireSourcesDuScenario(); const { texte, aEcrire, gardees, retirees } = await preparer(context, base, declarees, racineNeutre, machine, sources); await mkdir(join(base, DOSSIER_CAPTURES), { recursive: true }); for (const { fichier, octets } of aEcrire) await ecrireAtomique(join(base, cheminImage(fichier)), octets); for (const fichier of retirees) await rm(join(base, cheminImage(fichier))); const ancien = await lireSiPresent(join(base, CHEMIN_MANIFESTE), 'utf8'); if (ancien !== texte) await ecrireAtomique(join(base, CHEMIN_MANIFESTE), texte); return { ecrites: aEcrire.map(({ fichier }) => fichier), gardees, retirees, manifeste: ancien !== texte }; } finally { await rm(join(base, ATTENTE), { recursive: true, force: true }); } } /** Vide le dossier d'attente de la racine : un parcours en écriture part * de lui vide, et le dépôt d'un passage interrompu ne se publie jamais * avec les captures d'un autre. */ export async function viderAttente(_context, { racine = null } = {}) { await rm(join(racineDe(racine), ATTENTE), { recursive: true, force: true }); } /** Crée un dossier d'épreuve neuf, sous le dossier temporaire du système, * et rend son chemin absolu : une racine que les autres commandes * acceptent jusqu'à effacerDossierEpreuve. */ export async function dossierEpreuve() { const dossier = await mkdtemp(join(tmpdir(), 'gtt-captures-')); DOSSIERS_EPREUVE.add(dossier); return dossier; } /** Efface un dossier que dossierEpreuve a créé, et lui seul. */ export async function effacerDossierEpreuve(_context, racine) { if (!DOSSIERS_EPREUVE.has(racine)) throw new Error(`captures : « ${racine} » n’est pas un dossier d’épreuve vivant`); DOSSIERS_EPREUVE.delete(racine); await rm(racine, { recursive: true, force: true }); } /** * Copie un fichier d'un dossier d'épreuve sur un autre chemin du même * dossier, les deux sous doc/captures : une image posée là où la * publication ne l'a pas mise. La racine du projet est refusée. * * @param {Object} _context * @param {{racine: string, depuis: string, vers: string}} chemins relatifs à racine */ export async function copierDansEpreuve(_context, { racine, depuis, vers }) { if (!DOSSIERS_EPREUVE.has(racine)) throw new Error(`captures : « ${racine} » n’est pas un dossier d’épreuve vivant`); const captures = join(racine, DOSSIER_CAPTURES); const dedans = (chemin) => { const absolu = resolve(racine, String(chemin)); const relatif = relative(captures, absolu); if (relatif === '' || relatif.startsWith('..') || isAbsolute(relatif)) { throw new Error(`captures : ${JSON.stringify(chemin)} hors de ${DOSSIER_CAPTURES}`); } return absolu; }; const source = dedans(depuis); const cible = dedans(vers); await mkdir(dirname(cible), { recursive: true }); await ecrireAtomique(cible, await readFile(source)); } /** Les fichiers sous doc/captures de la racine, dossier d'attente compris : * [chemin relatif à la racine, SHA-256], rangés par chemin. */ export async function empreintesDossier(_context, { racine = null } = {}) { const base = racineDe(racine); const fichiers = []; async function parcourir(dossier) { let entrees; try { entrees = await readdir(dossier, { withFileTypes: true }); } catch (erreur) { if (erreur.code === 'ENOENT') return; throw erreur; } for (const entree of entrees) { const chemin = join(dossier, entree.name); if (entree.isDirectory()) await parcourir(chemin); else fichiers.push([relative(base, chemin).split('\\').join('/'), sha256(await readFile(chemin))]); } } await parcourir(join(base, DOSSIER_CAPTURES)); return fichiers.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); } // --- Le tramage des pages de la planche (§ 19.6) --------------------------------- /** La résolution du tramage, en points par pouce : celle du pixel CSS, que * mesurent les captures du navigateur. */ export const PPP_TRAMAGE = 96; /** Le mode qu'inscrit l'entrée d'une page de PDF : le lecteur qui la trame et * sa résolution, là où une capture du navigateur inscrit son mode * d'affichage. */ export const MODE_TRAMAGE = `pdfjs-dist-${PPP_TRAMAGE}ppp`; // Le canevas natif que pdfjs-dist charge pour tramer sous node, sa // dépendance optionnelle ; le point PDF et le millimètre, par pouce. const CANEVAS = '@napi-rs/canvas'; const PT_PAR_POUCE = 72; const MM_PAR_POUCE = 25.4; // Le chargeur CommonJS du paquet pdfjs-dist : un module qu'il résout est // celui que pdfjs-dist charge lui-même. const depuisPdfjs = () => createRequire(createRequire(import.meta.url).resolve('pdfjs-dist/package.json')); // Les polices standard de pdfjs-dist, lues du disque quand un document en // appelle une sans l'embarquer, comme test/pdf.js les lui donne. const policesStandard = () => `${join(dirname(createRequire(import.meta.url).resolve('pdfjs-dist/package.json')), 'standard_fonts')}/`; /** Le canevas natif de pdfjs-dist, chargé depuis son paquet. Lève quand il * manque, ou que sa liaison native n'existe pas pour ce système. */ export function chargerCanevas() { return depuisPdfjs()(CANEVAS); } /** Vrai quand le canevas natif de pdfjs-dist se charge sur ce système. */ export function canevasDisponible() { try { chargerCanevas(); return true; } catch { return false; } } // Les octets que porte un texte base64 ; lève sur un texte vide ou qui // n'est pas du base64 canonique, que la relecture ne rendrait pas tel quel. function octetsDe(octetsB64) { if (typeof octetsB64 !== 'string' || octetsB64.length === 0) { throw new TypeError('tramerPdf : octetsB64 est le texte base64, non vide, du PDF'); } const octets = Buffer.from(octetsB64, 'base64'); if (octets.toString('base64') !== octetsB64) throw new TypeError('tramerPdf : octetsB64 n’est pas du base64'); return new Uint8Array(octets.buffer, octets.byteOffset, octets.byteLength); } // Les pages demandées, [{page, fichier}] : au moins une, chaque nom de // capture valide et une seule fois, chaque page un entier. function pagesDemandees(pages) { if (!Array.isArray(pages) || pages.length === 0) throw new Error('tramerPdf : aucune page demandée'); const vus = new Set(); return pages.map(({ page, fichier }) => { exigerNom(fichier); if (vus.has(fichier)) throw new Error(`tramerPdf : « ${fichier} » demandée deux fois`); vus.add(fichier); if (!Number.isSafeInteger(page)) throw new TypeError(`tramerPdf : page ${JSON.stringify(page)}, un entier attendu`); return { page, fichier }; }); } // La taille en pixels d'une longueur en millimètres, à PPP_TRAMAGE. const enPixels = (mm) => Math.round((mm * PPP_TRAMAGE) / MM_PAR_POUCE); // Trame les pages données, [{page, cadre}], en PNG, dans l'ordre : chaque // page sur un canevas de son cadre, fond blanc, à PPP_TRAMAGE. Rend leurs // octets ; lève quand la page tramée n'a pas la taille de son cadre — une // page tournée par /Rotate, que le cadre lu des boîtes ne suit pas. async function tramerPages(octets, pages, canevas) { const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs'); const tache = getDocument({ data: new Uint8Array(octets), verbosity: 0, isEvalSupported: false, disableFontFace: true, useSystemFonts: false, standardFontDataUrl: policesStandard(), }); const document = await tache.promise; try { const images = []; for (const { page: rang, cadre } of pages) { const page = await document.getPage(rang); const viewport = page.getViewport({ scale: PPP_TRAMAGE / PT_PAR_POUCE }); const largeur = Math.round(viewport.width); const hauteur = Math.round(viewport.height); if (largeur !== cadre.largeur || hauteur !== cadre.hauteur) { throw new Error(`tramerPdf : page ${rang} tramée à ${largeur} × ${hauteur}, son cadre est ${cadre.largeur} × ${cadre.hauteur}`); } const toile = canevas.createCanvas(largeur, hauteur); const contexte = toile.getContext('2d'); await page.render({ canvasContext: contexte, canvas: toile, viewport, background: '#ffffff' }).promise; images.push(new Uint8Array(await toile.encode('png'))); } return images; } finally { await tache.destroy(); } } /** * Lit les pages demandées d'un PDF et, quand ecrire est vrai, les trame en * PNG sous le dossier d'attente de la racine, à .png (§ 19.6). * Dans l'ordre : les pages demandées et les octets se contrôlent, le PDF se * lit (test/pdf.js), chaque page doit exister ; puis, en écriture, le * canevas se charge — absent, le refus le nomme et rien ne s'écrit —, toutes * les pages se trament, et alors seulement les images s'écrivent. Sans * écriture, aucun canevas n'est chargé. * * @param {{octetsB64: string, pages: Array<{page: number, fichier: string}>, * racine?: string|null, ecrire?: boolean}} demande * @param {{chargerCanevas?: () => Object}} [options] le chargeur du canevas * @returns {Promise<{pdf: string, mode: string, pages: Array<{fichier: string, * page: number, cadre: {largeur: number, hauteur: number}, chemin: string|null, * textes: string[], segments: Array<{x1: number, y1: number, x2: number, y2: number}>}>}>} * pdf : le SHA-256 des octets ; cadre : la boîte de média à PPP_TRAMAGE, * en pixels arrondis ; chemin : l'image écrite, null sans écriture ; * textes : les fragments de la couche de texte, dans l'ordre du flux ; * segments : les traits droits, en mm, l'origine au coin haut gauche */ export async function tramer({ octetsB64, pages, racine = null, ecrire = true }, { chargerCanevas: charger = chargerCanevas } = {}) { const base = racineDe(racine); const demandees = pagesDemandees(pages); const octets = octetsDe(octetsB64); const { lirePdf } = await import('../pdf.js'); let lu; try { lu = await lirePdf(octets); } catch (erreur) { throw new Error(`tramerPdf : PDF illisible : ${erreur.message}`); } for (const { page } of demandees) { if (page < 1 || page > lu.pages.length) throw new Error(`tramerPdf : page ${page} hors du document de ${lu.pages.length} pages`); } const lues = demandees.map(({ page, fichier }) => { const { boite, textes, segments } = lu.pages[page - 1]; return { fichier, page, cadre: { largeur: enPixels(boite.largeur), hauteur: enPixels(boite.hauteur) }, chemin: null, textes: textes.map(({ texte }) => texte), segments, }; }); if (ecrire) { let canevas; try { canevas = charger(); } catch (erreur) { throw new Error( `tramerPdf : le canevas « ${CANEVAS} » manque — make captures l’exige pour tramer les pages du PDF ; rien n’est écrit (${erreur.message})`, ); } const images = await tramerPages(octets, lues, canevas); const dossier = join(base, ATTENTE); await mkdir(dossier, { recursive: true }); for (const [rang, lue] of lues.entries()) { lue.chemin = join(dossier, `${lue.fichier}.png`); await ecrireAtomique(lue.chemin, images[rang]); } } return { pdf: sha256(octets), mode: MODE_TRAMAGE, pages: lues }; } /** * La commande de la page : tramer, sur le PDF que la page passe en base64 * (octetsB64) — aucun octet ne traverse en Uint8Array. Rend ce que le PDF * donne à lire et les chemins des images, jamais d'octets. */ export async function tramerPdf(_context, demande) { return tramer(demande ?? {}); }