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
674 lines
30 KiB
JavaScript
674 lines
30 KiB
JavaScript
// © 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 <fichier>.png et son entrée complète
|
||
// <fichier>.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.
|
||
import { createHash } from 'node:crypto';
|
||
import { mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||
import { createRequire } from 'node:module';
|
||
import { tmpdir } 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 { 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<string, string|undefined>} 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, à
|
||
* <fichier>.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;
|
||
}
|
||
|
||
// 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) {
|
||
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)];
|
||
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 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 } = {}) {
|
||
const base = racineDe(racine);
|
||
try {
|
||
const { texte, aEcrire, gardees, retirees } = await preparer(context, base, declarees, racineNeutre);
|
||
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, à <fichier>.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 ?? {});
|
||
}
|