gestion_table_tournante_libre/test/navigateur/commandes_documentation.js
Mathieu Benoit cb89060e34 [ADD] captures: badge control page and first sheet, rasterised from PDF
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
2026-10-10 14:36:21 -04:00

674 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// © 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 ?? {});
}