[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
This commit is contained in:
Mathieu Benoit 2026-10-10 14:36:21 -04:00
parent b72f3fe784
commit cb89060e34
10 changed files with 802 additions and 30 deletions

View file

@ -378,6 +378,33 @@ Revenu au placement retenu, l’aperçu sort sans filigrane :
porte l’instant de ce placement. C’est cette page qui part à l’imprimante pour
le montage de la salle.*
La planche se produit dans la section « Badges » de l’événement. On y choisit
l’imposition — feuille par feuille, ou en pile pour un massicot qui tranche
toute la rame — et l’ordre des badges, alphabétique ou par table du premier
tour. « Préparer » annonce ce qui sortira : le nombre de badges et de planches,
les homonymes, les caractères que la police ne dessine pas. « Produire la
planche » écrit le PDF à côté des fichiers de l’événement et en affiche le
chemin ; dans un navigateur, le PDF se télécharge. Sa première page est la
feuille de contrôle :
![La feuille de contrôle de la planche : la règle de 100 mm, puis les comptes de la soirée](doc/captures/24-badges-controle.png)
*La règle du haut mesure 100 mm sur le papier : posez-y une vraie règle. Si les
deux ne coïncident pas, l’imprimante a mis la page à l’échelle, et les badges
ne tomberont pas sur les traits de coupe. Suivent le format du badge et du
papier, l’imposition, le nombre de planches, les comptes de la soirée — un
badge par personne présente, les exclus comptés à part —, l’instant du
placement et la version du logiciel.*
Viennent ensuite les planches, quatre badges A6 par feuille A4 :
![La première planche : quatre badges A6 et leurs traits de coupe](doc/captures/25-badges-planche.png)
*Chaque badge porte le nom, l’appartenance et la table de chaque tour ; en
pied, le nom de l’événement et l’instant du placement. Les traits fins
marquent la coupe : un vertical au milieu de la feuille, un horizontal sous
chaque rangée de badges.*
Une fois le plan imprimé, bloquez-le : choisissez l’état « Bloqué » en haut de
l’événement. Plus rien ne le modifie, jusqu’à « Débloquer ».

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

File diff suppressed because one or more lines are too long

View file

@ -80,7 +80,8 @@ export const REPORTEES = Object.freeze([
// Captures déclarées dans CAPTURES et pas encore photographiées : la
// publication et le contrôle d'ordre les admettent absentes du manifeste.
export const A_VENIR = Object.freeze(['24-badges-controle', '25-badges-planche']);
// Vide : le parcours photographie chaque capture déclarée.
export const A_VENIR = Object.freeze([]);
// --- Forme canonique et empreintes ---------------------------------------------

View file

@ -142,12 +142,11 @@ describe('manifeste : la liste déclarée (§ 19.3, Q8 a)', () => {
for (const reportee of REPORTEES) assert.ok(Object.isFrozen(reportee), reportee.fichier);
});
test('A_VENIR nomme les deux captures de la planche, à l’étape 12, déclarées ; 22 et 23 sont photographiées', () => {
assert.deepEqual(A_VENIR, ['24-badges-controle', '25-badges-planche']);
for (const fichier of A_VENIR) assert.equal(CAPTURES.find((c) => c.fichier === fichier)?.etape, '12', fichier);
const declarees = new Set(CAPTURES.map((c) => c.fichier));
for (const fichier of A_VENIR) assert.ok(declarees.has(fichier), fichier);
test('A_VENIR est vide : chaque capture déclarée est photographiée, 24 et 25 tramées du PDF de la planche', () => {
assert.deepEqual(A_VENIR, []);
assert.ok(Object.isFrozen(A_VENIR));
const tramees = CAPTURES.filter((c) => c.source === 'pdf').map((c) => [c.fichier, c.etape]);
assert.deepEqual(tramees, [['24-badges-controle', '12'], ['25-badges-planche', '12']]);
});
test('CAPTURES ∪ REPORTEES : des noms uniques, et l’ordre des noms suit celui des étapes', () => {
@ -316,21 +315,22 @@ describe('manifeste : l’ordre contre les étapes du § 2.1 (§ 19.3)', () => {
assert.deepEqual(verifierOrdre(manifesteDeclare()), []);
});
test('les captures d’A_VENIR, absentes du manifeste, sont admises', () => {
assert.deepEqual(verifierOrdre(manifesteDeclare({ sans: A_VENIR })), []);
test('une capture à venir, absente du manifeste, couvre son étape', () => {
const etape12 = ['22-plan-brouillon', '23-plan-retenu', '24-badges-controle', '25-badges-planche'];
assert.deepEqual(verifierOrdre(manifesteDeclare({ sans: etape12 }), { aVenir: ['24-badges-controle'] }), []);
});
test('A_VENIR vidé, les captures d’A_VENIR absentes : 22 et 23 couvrent l’étape 12', () => {
assert.deepEqual(verifierOrdre(manifesteDeclare({ sans: A_VENIR }), { aVenir: [] }), []);
});
test('A_VENIR vidé, sans 22 ni 23 : l’étape 12 sans capture est une faute', () => {
const photographiees = ['22-plan-brouillon', '23-plan-retenu'];
assert.deepEqual(verifierOrdre(manifesteDeclare({ sans: [...photographiees, ...A_VENIR] }), { aVenir: [] }), [
test('A_VENIR par défaut, vide : sans ses quatre captures, l’étape 12 est une faute', () => {
const etape12 = ['22-plan-brouillon', '23-plan-retenu', '24-badges-controle', '25-badges-planche'];
assert.deepEqual(verifierOrdre(manifesteDeclare({ sans: etape12 })), [
{ chemin: CHEMIN_MANIFESTE, faute: 'étape 12 : aucune capture ni reportée' },
]);
});
test('sans 24 ni 25, 22 et 23 couvrent l’étape 12 : l’appariement, non l’ordre, réclame les images de la planche', () => {
assert.deepEqual(verifierOrdre(manifesteDeclare({ sans: ['24-badges-controle', '25-badges-planche'] })), []);
});
test('une étape sans capture ni reportée est une faute qui la nomme', () => {
assert.deepEqual(verifierOrdre(manifesteDeclare({ sans: ['07b-nombre-de-tours'] })), [
{ chemin: CHEMIN_MANIFESTE, faute: 'étape 5 : aucune capture ni reportée' },

View file

@ -15,7 +15,9 @@
// captures du § 19.3 que l'itération photographie — 01, 01b, 02, 02b, 03 à
// 07, 07b, 08 à 10, les deux 07a sur la grande démonstration que 10 a
// chargée, 11, 19, 20, puis 22 et 23, l'aperçu avant impression d'une
// proposition et du retenu —, puis l'ordre de lecture du § 2.1 sur
// proposition et du retenu, puis 24 et 25, la feuille de contrôle et la
// première planche du PDF que la section Badges produit, tramées sous node
// (§ 19.6) —, puis l'ordre de lecture du § 2.1 sur
// l'événement retenu, les fenêtres — aucune pendant un geste non
// destructeur, une qui nomme ce qui part pour chaque geste qui détruit —,
// enfin chaque famille de geste modifiant en lecture puis sur un plan
@ -25,7 +27,8 @@
// état du document (§ 19.3).
//
// Une épreuve qui porte une capture finit, ses conditions affirmées, par
// photographier (test/navigateur/captures.js) ; une épreuve qui continue
// photographier (test/navigateur/captures.js), ou, pour une page du PDF de
// la planche, par photographierPdf ; une épreuve qui continue
// au-delà de l'écran photographié se coupe en deux. Rien de la machine
// n'entre dans une région photographiée : la provenance « documentation »
// tient lieu de version (§ 18.6), l'exécuteur direct annonce quatre cœurs,
@ -42,7 +45,7 @@ import { flushSync } from 'svelte';
import { bilanRemplacement, cleNormalisee } from '../application/csv.js';
import { creerFormat } from '../application/format.js';
import { creerExecuteurDirect } from '../application/generation.js';
import { TABLES_APPLICATION } from '../application/libelles.js';
import { TABLES_APPLICATION, libelle } from '../application/libelles.js';
import { nomAffiche } from '../application/personnes.js';
import { etatPlaces } from '../application/places.js';
import { estBrouillon } from '../application/sorties.js';
@ -69,7 +72,15 @@ import { SUFFIXES } from '../stockage/noms.js';
import { planDepuisFichier } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js';
import urlCp1252 from '../../exemples/participants_cas_limites_cp1252.csv?url';
import { CADRE, capturesPrises, cloreCaptures, entiereDansLaFenetre, modeFourni, photographier } from '../../test/navigateur/captures.js';
import {
CADRE,
capturesPrises,
cloreCaptures,
empreinteEtat,
entiereDansLaFenetre,
modeFourni,
photographier,
} from '../../test/navigateur/captures.js';
import { NOM_CAS_LIMITES, attendre, centre, octetsCasLimites } from '../../test/navigateur/ecrans.js';
import { fixerCadre, pointer, touche } from '../../test/navigateur/entrees.js';
import { poserFeuille } from '../../test/navigateur/feuilles.js';
@ -590,6 +601,121 @@ async function cadrerSur(elements) {
await images(2);
}
// --- La planche de badges photographiée (§ 19.6) --------------------------------------
// Les images de la planche (§ 19.3, § 19.6) : la feuille de contrôle, page 1
// du PDF, et la première planche, page 2.
const PAGE_CONTROLE = Object.freeze({ page: 1, fichier: '24-badges-controle' });
const PAGE_PLANCHE = Object.freeze({ page: 2, fichier: '25-badges-planche' });
// Les images que la production de la planche attend au plus : la police et
// pdf-lib se chargent à la première production.
const IMAGES_PLANCHE = 1800;
// L'écart de l'horloge pilotée entre deux productions de la même planche :
// une date du document tirée de l'horloge, et non du placement, les
// séparerait à la seconde.
const ECART_PRODUCTIONS_MS = 60 * 1000;
// Les captures tramées du PDF que le parcours a menées à leur terme.
const capturesPdf = new Set();
// Le texte base64 d'octets : le PDF traverse ainsi le RPC des commandes,
// qui sérialiserait un Uint8Array en objet.
function enBase64(octets) {
let binaire = '';
for (let debut = 0; debut < octets.length; debut += 0x8000) {
binaire += String.fromCharCode(...octets.subarray(debut, debut + 0x8000));
}
return btoa(binaire);
}
// Le SHA-256 hexadécimal d'octets.
async function empreinte(octets) {
const tampon = await crypto.subtle.digest('SHA-256', octets);
return [...new Uint8Array(tampon)].map((octet) => octet.toString(16).padStart(2, '0')).join('');
}
// Le texte d'une page de PDF lue, ses fragments joints, blancs réduits.
const texteDePage = (lue) => plat(lue.textes.join(' '));
// Les traits droits d'une page de PDF lue, en mm : les abscisses des
// verticaux et les ordonnées des horizontaux, puis les longueurs des
// horizontaux, au centième, chacune une fois, croissantes.
function axesDe(lue) {
const centieme = (valeur) => Math.round(valeur * 100) / 100;
const uniques = (valeurs) => [...new Set(valeurs.map(centieme))].sort((a, b) => a - b);
const verticaux = lue.segments.filter(({ x1, x2 }) => Math.abs(x1 - x2) < 1e-3);
const horizontaux = lue.segments.filter(({ y1, y2 }) => Math.abs(y1 - y2) < 1e-3);
return {
x: uniques(verticaux.map(({ x1 }) => x1)),
y: uniques(horizontaux.map(({ y1 }) => y1)),
longueurs: uniques(horizontaux.map(({ x1, x2 }) => Math.abs(x2 - x1))),
};
}
// Compare deux textes point de code par point de code, sans tenir compte
// de la langue, comme l'ordre alphabétique de la planche (§ 11.6, § 15.5).
function parPointsDeCode(a, b) {
const [x, y] = [[...a], [...b]];
for (let rang = 0; rang < Math.min(x.length, y.length); rang += 1) {
const ecart = x[rang].codePointAt(0) - y[rang].codePointAt(0);
if (ecart !== 0) return ecart;
}
return x.length - y.length;
}
/**
* Photographie une page du PDF de la planche (§ 19.6) : tramerPdf la lit
* d'abord sans rien écrire, conditions(lue) affirme ce qu'elle montre, puis,
* en écriture, la trame en PNG sous le dossier d'attente et dépose l'entrée ;
* en vérification, compare l'entrée au manifeste. L'entrée porte la source
* pdf, la taille de la page à 96 ppp pour cadre, la couche de texte de la
* page pour texte, aucun style — le PDF n'a pas de thème d'écran — et le
* SHA-256 des octets pour pdf.
*
* @param {Uint8Array} octets le PDF produit
* @param {{page: number, fichier: string}} quoi
* @param {(lue: Object) => void} conditions
*/
async function photographierPdf(octets, { page, fichier }, conditions) {
const { captures } = await commands.declarationsCaptures();
const declaree = captures.find((capture) => capture.fichier === fichier);
expect(declaree, `${fichier} déclarée`).toMatchObject({ source: 'pdf' });
const octetsB64 = enBase64(octets);
const lu = await commands.tramerPdf({ octetsB64, pages: [{ page, fichier }], ecrire: false });
expect(lu.pdf).toBe(await empreinte(octets));
const [lue] = lu.pages;
conditions(lue);
const racineNeutre = etat().racine.chemin;
expect(await commands.cheminsHorsRacine({ texte: lue.textes, racine: racineNeutre }), fichier).toEqual([]);
const entree = {
fichier,
etape: declaree.etape,
source: 'pdf',
versionReelle: VERSION.affichee,
graine: GRAINE,
cadre: lue.cadre,
facteurEchelle: 1,
mode: lu.mode,
theme: 'clair',
instant: m.horloge.horodatage(),
texte: lue.textes,
styles: { clair: [], sombre: [] },
etat: await empreinteEtat(m),
pdf: lu.pdf,
};
if (modeFourni() === 'ecrire') {
const tramee = await commands.tramerPdf({ octetsB64, pages: [{ page, fichier }], ecrire: true });
expect(tramee.pdf).toBe(lu.pdf);
await commands.deposerCapture({ fichier, entree, chemin: tramee.pages[0].chemin });
} else {
const { ecarts } = await commands.comparerCapture({ fichier, entree });
expect(ecarts, `capture ${fichier} : écart au manifeste`).toEqual([]);
}
capturesPdf.add(fichier);
}
// --- Le parcours (§ 2.1, § 19.3) ------------------------------------------------------
describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE }, () => {
@ -1318,9 +1444,95 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE
await images(2);
});
// Un échec de 22 ou de 23 laisse l'aperçu ouvert, la proposition montrée
// et la fenêtre défilée : l'état est rendu ici, pour que les épreuves
// suivantes jugent leurs propres gardes et non ce reste.
// La planche du retenu, produite par la section Badges sur le papier que
// 22 a choisi, puis tramée sous node (§ 19.6) : la feuille de contrôle,
// puis la première planche. Produire n'écrit que le PDF ; deux
// productions sous l'horloge pilotée donnent les mêmes octets, que
// l'empreinte du manifeste suit.
test('24 la planche du retenu par la section Badges : seul le PDF s’écrit, deux productions identiques ; la feuille de contrôle', async () => {
expect(charge().evenement.etat).toBe('retenu');
expect(etat().papier).toBe('A4');
const avant = fichiers();
let rang = m.trace.length;
await ouvrirSection('badges');
await cliquer('[data-section="badges"] [data-commande="produirePlanche"]');
const affiche = await attendre('la planche produite', () => dans('[data-section="badges"] [data-chemin-planche]'), IMAGES_PLANCHE);
const ecrits = cheminsEcrits(rang);
expect(ecrits).toHaveLength(1);
const [nom] = ecrits;
expect(nom.startsWith(`${base()}.badges_A4_feuille_`), nom).toBe(true);
expect(nom.endsWith('.pdf'), nom).toBe(true);
const { separateur } = await m.fs.emplacements();
expect(texteDe(affiche)).toBe(`${etat().racine.chemin}${separateur}${nom}`);
const premiere = m.fs.octets('documents', nom);
expect(new TextDecoder().decode(premiere.slice(0, 5))).toBe('%PDF-');
// Une seconde production, l'horloge avancée : le même fichier, les
// mêmes octets. L'avance échoit aussi l'écriture différée du cadrage
// (reglages_locaux.json), que le plan a laissée en attente : elle passe
// avant que la trace de la seconde production ne commence.
m.horloge.avancer(ECART_PRODUCTIONS_MS);
await images(4);
rang = m.trace.length;
await cliquer('[data-section="badges"] [data-commande="produirePlanche"]');
await attendre(
'la seconde planche',
() => cheminsEcrits(rang).length > 0 && dans('[data-section="badges"] [data-chemin-planche]'),
IMAGES_PLANCHE,
);
expect(cheminsEcrits(rang)).toEqual([nom]);
const seconde = m.fs.octets('documents', nom);
expect(await empreinte(seconde)).toBe(await empreinte(premiere));
await rienEcrit(avant, 'la production de la planche');
memoire.planche = seconde;
// La feuille de contrôle : la règle de 100 mm, les comptes de la
// soirée, la marque de provenance à la place de la version, aucun
// BROUILLON sur le retenu.
const participants = charge().participants.length;
const exclus = charge().participants.filter(({ exclu }) => exclu).length;
const produits = participants - exclus;
const version = versionVisible('documentation', VERSION.affichee, libelle);
await photographierPdf(seconde, PAGE_CONTROLE, (lue) => {
const texte = texteDePage(lue);
expect(axesDe(lue).longueurs).toContain(100);
expect(texte).toContain(plat(libelle('planche.controle.comptes', { produits, participants, exclus })));
expect(texte).toContain(plat(libelle('planche.controle.planches', { planches: Math.ceil(produits / 4) })));
expect(texte).toContain(plat(libelle('planche.controle.version', { version })));
expect(texte).not.toContain(VERSION.affichee);
expect(texte).not.toContain(libelle('planche.brouillon'));
});
});
test('25 la première planche : quatre A6, ses trois filets de coupe, les quatre premiers noms dans l’ordre alphabétique', async () => {
expect(memoire.planche, 'la planche de 24').toBeInstanceOf(Uint8Array);
const presents = charge().participants.filter(({ exclu }) => !exclu);
expect(presents.length).toBeGreaterThanOrEqual(4);
// L'ordre alphabétique de la planche : le nom, puis le prénom, leurs
// clés normalisées comparées par points de code, puis l'identifiant.
const cle = (texte) => cleNormalisee(texte ?? '');
const premiers = [...presents]
.sort((a, b) => parPointsDeCode(cle(a.nom), cle(b.nom)) || parPointsDeCode(cle(a.prenom), cle(b.prenom)) || a.id - b.id)
.slice(0, 4)
.map((personne) => nomAffiche(personne).normalize('NFC'));
await photographierPdf(memoire.planche, PAGE_PLANCHE, (lue) => {
// A4, planche pleine : la coupe verticale au milieu, les deux
// horizontales sous chaque rangée de badges (§ 11.6).
const { x, y } = axesDe(lue);
expect(x).toEqual([105]);
expect(y).toEqual([148, 296]);
const texte = texteDePage(lue);
for (const nomBadge of premiers) expect(texte, nomBadge).toContain(plat(nomBadge));
expect(texte).not.toContain(libelle('planche.brouillon'));
});
// Le parcours reprend sur le plan, comme 23 l'a laissé.
await ouvrirSection('plan');
});
// Un échec de 22 à 25 laisse l'aperçu ouvert, la proposition montrée, la
// section Badges ouverte ou la fenêtre défilée : l'état est rendu ici,
// pour que les épreuves suivantes jugent leurs propres gardes et non ce
// reste.
afterAll(async () => {
if (dans('[data-ecran="apercu"] [data-commande="fermerApercu"]') !== null) {
await cliquer('[data-ecran="apercu"] [data-commande="fermerApercu"]');
@ -1330,6 +1542,7 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE
await cliquer('[data-commande="quitterProposition"]');
await attendre('le retenu montré', () => m.ui.etat().proposition === null);
}
if (dans('[data-section="plan"]') === null) await ouvrirSection('plan');
window.scrollTo(0, 0);
});
});
@ -1767,6 +1980,6 @@ describe('lecture et bloqué : tout geste modifiant refusé, rien d’écrit', {
describe('les captures d’écran du parcours (§ 19.5, § 19.7)', () => {
test('chaque capture déclarée photographiée : publiées d’un bloc en écriture, conformes au manifeste en vérification', async () => {
const { declarees } = await cloreCaptures({ echecs: epreuvesEnEchec, racineNeutre: m?.seance.etat().racine?.chemin ?? null });
expect([...capturesPrises()].sort()).toEqual([...declarees].sort());
expect([...capturesPrises(), ...capturesPdf].sort()).toEqual([...declarees].sort());
});
});

View file

@ -29,8 +29,20 @@
//
// 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';
@ -71,6 +83,7 @@ export const COMMANDES = Object.freeze([
'effacerDossierEpreuve',
'empreintesDossier',
'copierDansEpreuve',
'tramerPdf',
]);
/**
@ -293,10 +306,14 @@ async function deposees(attente) {
}
// Le pilote du manifeste : le navigateur et sa version, le mode nommé, le
// facteur d'échelle et le cadre, communs à toutes les captures.
// 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 [premiere] = captures;
for (const capture of 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}`);
}
@ -476,3 +493,182 @@ export async function empreintesDossier(_context, { racine = null } = {}) {
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 ?? {});
}

View file

@ -0,0 +1,332 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves sous node des commandes des captures qui touchent au PDF de la
// planche (§ 19.5, § 19.6) : tramerPdf lit chaque page demandée — texte,
// traits, taille à 96 ppp —, la trame en PNG sous le dossier d'attente quand
// on le lui demande, et refuse en nommant le canevas quand il manque ; la
// publication admet, à côté des captures du navigateur, une page de PDF à la
// taille de sa page. Chaque épreuve travaille dans un dossier d'épreuve
// (dossierEpreuve), effacé à la fin.
//
// Les PDF des épreuves se construisent ici par pdf-lib, une fois par
// fichier : le premier, une page A4 qui porte un trait de 100 mm et une ligne
// de texte, une page A6 qui porte un nom inventé, dans la police embarquée ;
// le second, deux pages vides aux dimensions de deux cadres d'écran. Toute
// image s'écrit par tramerPdf : l'épreuve n'écrit rien elle-même.
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import fontkit from '@pdf-lib/fontkit';
import { PDFDocument, grayscale } from 'pdf-lib';
import { describe, test } from '../lanceur.js';
import { lirePolices } from '../polices.js';
import { VERSION } from '../../src/version.genere.js';
import { CHEMIN_MANIFESTE, DOSSIER_CAPTURES, lireEntetePng } from '../../scripts/documentation/manifeste.js';
import {
COMMANDES,
MODE_TRAMAGE,
PPP_TRAMAGE,
canevasDisponible,
chargerCanevas,
deposerCapture,
dossierEpreuve,
effacerDossierEpreuve,
empreintesDossier,
lireManifeste,
publierCaptures,
tramer,
tramerPdf,
} from './commandes_documentation.js';
const sha256 = (octets) => createHash('sha256').update(octets).digest('hex');
const enBase64 = (octets) => Buffer.from(octets).toString('base64');
// Le point typographique et le pixel CSS, en millimètres.
const PT_PAR_MM = 72 / 25.4;
const PX_PAR_MM = 96 / 25.4;
// Les deux pages du PDF des épreuves, en mm, et ce que chacune porte.
const A4 = Object.freeze({ largeur: 210, hauteur: 297 });
const A6 = Object.freeze({ largeur: 105, hauteur: 148 });
const LIGNE_A4 = 'Feuille témoin';
const NOM_A6 = 'Ondine Lacasse';
// Le trait de la page A4 : 100 mm, à 20 mm du bord gauche et 30 mm du haut.
const TRAIT = Object.freeze({ x: 20, y: 30, longueur: 100 });
// La racine neutre des textes relevés : inventée, elle n'existe nulle part
// ailleurs dans le dépôt.
const RACINE_NEUTRE = 'C:\\Seance\\Tables';
// Le dossier d'attente d'une racine, et le chemin d'une image déposée.
const ATTENTE = `${DOSSIER_CAPTURES}/.attente`;
const deposee = (racine, fichier) => join(racine, ATTENTE, `${fichier}.png`);
// La taille attendue d'une page à 96 ppp : ses millimètres en pixels CSS,
// arrondis à l'entier.
const enPixels = ({ largeur, hauteur }) => ({ largeur: Math.round(largeur * PX_PAR_MM), hauteur: Math.round(hauteur * PX_PAR_MM) });
async function construirePdf() {
const document = await PDFDocument.create({ updateMetadata: false });
document.registerFontkit(fontkit);
const police = await document.embedFont(lirePolices().regulier, { subset: true });
const a4 = document.addPage([A4.largeur * PT_PAR_MM, A4.hauteur * PT_PAR_MM]);
const yTrait = (A4.hauteur - TRAIT.y) * PT_PAR_MM;
a4.drawLine({
start: { x: TRAIT.x * PT_PAR_MM, y: yTrait },
end: { x: (TRAIT.x + TRAIT.longueur) * PT_PAR_MM, y: yTrait },
thickness: 0.5,
color: grayscale(0),
});
a4.drawText(LIGNE_A4, { x: TRAIT.x * PT_PAR_MM, y: yTrait - 40, size: 14, font: police, color: grayscale(0) });
const a6 = document.addPage([A6.largeur * PT_PAR_MM, A6.hauteur * PT_PAR_MM]);
a6.drawText(NOM_A6, { x: 30, y: 200, size: 18, font: police, color: grayscale(0) });
return document.save();
}
// Deux pages vides de la taille de deux cadres d'écran, en px CSS : le
// cadre des captures du navigateur, et un autre.
const ECRAN = Object.freeze({ largeur: 1366, hauteur: 700 });
const AUTRE_ECRAN = Object.freeze({ largeur: 1280, hauteur: 700 });
async function construireEcrans() {
const document = await PDFDocument.create({ updateMetadata: false });
for (const { largeur, hauteur } of [ECRAN, AUTRE_ECRAN]) document.addPage([(largeur * 72) / 96, (hauteur * 72) / 96]);
return document.save();
}
const OCTETS = await construirePdf();
const B64 = enBase64(OCTETS);
const PAGES = Object.freeze([
{ page: 1, fichier: '24-badges-controle' },
{ page: 2, fichier: '25-badges-planche' },
]);
// Un chargeur de canevas qui échoue comme un paquet optionnel absent.
const canevasAbsent = () => {
const erreur = new Error("Cannot find module '@napi-rs/canvas'");
erreur.code = 'MODULE_NOT_FOUND';
throw erreur;
};
// Exécute faire(racine) dans un dossier d'épreuve neuf, effacé ensuite.
async function dansUnDossier(faire) {
const racine = await dossierEpreuve();
try {
return await faire(racine);
} finally {
await effacerDossierEpreuve(null, racine);
}
}
// La raison d'un saut quand le canevas manque : le tramage ne s'éprouve que
// là où make captures peut tramer.
const SANS_CANEVAS = 'le canevas de pdfjs (@napi-rs/canvas) manque sur ce système : le tramage ne s’y éprouve pas';
// Le nombre de pixels d'une image PNG plus sombres que le gris moyen.
async function pixelsSombres(octets) {
const { createCanvas, loadImage } = chargerCanevas();
const image = await loadImage(Buffer.from(octets));
const canevas = createCanvas(image.width, image.height);
const contexte = canevas.getContext('2d');
contexte.drawImage(image, 0, 0);
const { data } = contexte.getImageData(0, 0, image.width, image.height);
let sombres = 0;
for (let i = 0; i < data.length; i += 4) if (data[i] + data[i + 1] + data[i + 2] < 3 * 128) sombres += 1;
return sombres;
}
describe('tramerPdf : la lecture des pages d’un PDF (§ 19.6)', () => {
test('déclarée parmi les commandes, exportée sous son nom', () => {
assert.ok(COMMANDES.includes('tramerPdf'));
assert.equal(typeof tramerPdf, 'function');
});
test('sans écrire : le texte, les traits et la taille à 96 ppp de chaque page demandée, l’empreinte des octets ; aucun canevas chargé, rien d’écrit', async () => {
await dansUnDossier(async (racine) => {
const lu = await tramer({ octetsB64: B64, pages: PAGES, racine, ecrire: false }, { chargerCanevas: canevasAbsent });
assert.equal(lu.pdf, sha256(OCTETS));
assert.equal(lu.mode, MODE_TRAMAGE);
assert.equal(PPP_TRAMAGE, 96);
assert.deepEqual(
lu.pages.map(({ fichier, page, cadre, chemin }) => ({ fichier, page, cadre, chemin })),
[
{ fichier: '24-badges-controle', page: 1, cadre: enPixels(A4), chemin: null },
{ fichier: '25-badges-planche', page: 2, cadre: enPixels(A6), chemin: null },
],
);
assert.deepEqual(enPixels(A4), { largeur: 794, hauteur: 1123 });
assert.deepEqual(lu.pages[0].textes, [LIGNE_A4]);
assert.deepEqual(lu.pages[1].textes, [NOM_A6]);
// pdf-lib écrit ses coordonnées en points à six décimales au plus.
const pres = (a, b) => Math.abs(a - b) < 1e-3;
const horizontaux = lu.pages[0].segments.filter(({ y1, y2 }) => pres(y1, y2));
assert.equal(horizontaux.length, 1);
const [trait] = horizontaux;
assert.ok(pres(Math.abs(trait.x2 - trait.x1), TRAIT.longueur), JSON.stringify(trait));
assert.ok(pres(trait.y1, TRAIT.y), JSON.stringify(trait));
assert.deepEqual(lu.pages[1].segments, []);
assert.deepEqual(await empreintesDossier(null, { racine }), []);
});
});
test('une seule page, la seconde : seule lue', async () => {
const lu = await tramer({ octetsB64: B64, pages: [PAGES[1]], ecrire: false }, { chargerCanevas: canevasAbsent });
assert.deepEqual(lu.pages.map(({ fichier, textes }) => [fichier, textes]), [['25-badges-planche', [NOM_A6]]]);
});
test('refus nommés, rien d’écrit : octets hors du base64, PDF illisible, aucune page, page hors du document, nom refusé, nom en double', async () => {
await dansUnDossier(async (racine) => {
const essayer = (args) => tramer({ racine, ecrire: true, ...args }, { chargerCanevas: canevasAbsent });
await assert.rejects(essayer({ octetsB64: 42, pages: PAGES }), /tramerPdf : octetsB64/);
await assert.rejects(essayer({ octetsB64: 'pas du base64 !', pages: PAGES }), /tramerPdf : octetsB64/);
await assert.rejects(essayer({ octetsB64: enBase64(new TextEncoder().encode('%PDF-1.7 tronqué')), pages: PAGES }), /tramerPdf : PDF illisible/);
await assert.rejects(essayer({ octetsB64: B64, pages: [] }), /tramerPdf : aucune page demandée/);
await assert.rejects(essayer({ octetsB64: B64, pages: [{ page: 3, fichier: '25-badges-planche' }] }), /tramerPdf : page 3 hors du document de 2 pages/);
await assert.rejects(essayer({ octetsB64: B64, pages: [{ page: 0, fichier: '25-badges-planche' }] }), /tramerPdf : page 0 hors du document de 2 pages/);
await assert.rejects(essayer({ octetsB64: B64, pages: [{ page: 1, fichier: '../hors' }] }), /nom de capture refusé/);
await assert.rejects(
essayer({ octetsB64: B64, pages: [PAGES[0], { page: 2, fichier: PAGES[0].fichier }] }),
/tramerPdf : « 24-badges-controle » demandée deux fois/,
);
assert.deepEqual(await empreintesDossier(null, { racine }), []);
});
});
});
describe('tramerPdf : le tramage, sous make captures (§ 19.6, décision 2)', () => {
test('canevas absent : le refus nomme le paquet, sa cause et make captures ; rien d’écrit', async () => {
await dansUnDossier(async (racine) => {
await assert.rejects(
tramer({ octetsB64: B64, pages: PAGES, racine, ecrire: true }, { chargerCanevas: canevasAbsent }),
(erreur) => {
assert.match(erreur.message, /^tramerPdf : le canevas « @napi-rs\/canvas » manque/);
assert.match(erreur.message, /make captures/);
assert.match(erreur.message, /Cannot find module/);
return true;
},
);
assert.deepEqual(await empreintesDossier(null, { racine }), []);
});
});
test('chaque page demandée tramée en PNG sous le dossier d’attente, à la taille de sa page à 96 ppp, son encre rendue', async (contexte) => {
if (!canevasDisponible()) contexte.skip(SANS_CANEVAS);
await dansUnDossier(async (racine) => {
const lu = await tramerPdf(null, { octetsB64: B64, pages: PAGES, racine });
assert.equal(lu.pdf, sha256(OCTETS));
for (const [rang, format] of [A4, A6].entries()) {
const { fichier, chemin, cadre } = lu.pages[rang];
assert.equal(chemin, deposee(racine, fichier));
const octets = new Uint8Array(await readFile(chemin));
assert.deepEqual(lireEntetePng(octets), enPixels(format), fichier);
assert.deepEqual(cadre, enPixels(format), fichier);
assert.ok((await pixelsSombres(octets)) > 0, `${fichier} : aucune encre tramée`);
}
const ecrits = (await empreintesDossier(null, { racine })).map(([chemin]) => chemin);
assert.deepEqual(ecrits, [`${ATTENTE}/24-badges-controle.png`, `${ATTENTE}/25-badges-planche.png`]);
});
});
test('les mêmes octets de PDF donnent les mêmes octets de PNG', async (contexte) => {
if (!canevasDisponible()) contexte.skip(SANS_CANEVAS);
const tramees = [];
for (let fois = 0; fois < 2; fois += 1) {
tramees.push(
await dansUnDossier(async (racine) => {
const { pages } = await tramerPdf(null, { octetsB64: B64, pages: PAGES, racine });
return Promise.all(pages.map(async ({ chemin }) => sha256(await readFile(chemin))));
}),
);
}
assert.deepEqual(tramees[1], tramees[0]);
assert.notEqual(tramees[0][0], tramees[0][1]);
});
});
// Le contexte de commande que lit la publication : la version du navigateur,
// inconnue ici.
const CONTEXTE = Object.freeze({ page: { context: () => ({ browser: () => null }) } });
const ECRANS = await construireEcrans();
// Dépose sous le dossier d'attente une capture de la source donnée : la page
// donnée, tramée par tramerPdf, en est l'image, et sa taille le cadre. Une
// page de PDF se tire du PDF des épreuves, une capture du navigateur des
// pages d'écran.
async function deposer(racine, { fichier, etape, source, page }) {
const octets = source === 'pdf' ? OCTETS : ECRANS;
const { pdf, mode, pages } = await tramerPdf(null, { octetsB64: enBase64(octets), pages: [{ page, fichier }], racine });
const [{ cadre, chemin }] = pages;
const entree = {
fichier,
etape,
source,
versionReelle: VERSION.affichee,
graine: null,
cadre,
facteurEchelle: 1,
mode: source === 'pdf' ? mode : 'chromium-headless-shell',
theme: 'clair',
instant: '2031-05-06T20:15:00-04:00',
texte: [fichier],
styles: { clair: [], sombre: [] },
etat: null,
pdf: source === 'pdf' ? pdf : null,
};
return deposerCapture(null, { fichier, entree, chemin, racine });
}
// Les images s'y écrivent par tramerPdf : sans canevas, ces épreuves ne
// s'y jouent pas.
describe('publierCaptures : une page de PDF à côté des captures du navigateur (§ 19.5, § 19.6)', () => {
test('une capture du navigateur et une page de PDF de cadres différents se publient ensemble ; le pilote porte le cadre du navigateur', async (contexte) => {
if (!canevasDisponible()) contexte.skip(SANS_CANEVAS);
await dansUnDossier(async (racine) => {
await deposer(racine, { fichier: '01-premier-ecran', etape: '1', source: 'navigateur', page: 1 });
await deposer(racine, { fichier: '24-badges-controle', etape: '12', source: 'pdf', page: 1 });
const bilan = await publierCaptures(CONTEXTE, {
declarees: ['01-premier-ecran', '24-badges-controle'],
racine,
racineNeutre: RACINE_NEUTRE,
});
assert.deepEqual(bilan.ecrites, ['01-premier-ecran', '24-badges-controle']);
const manifeste = await lireManifeste(null, { racine });
assert.deepEqual(manifeste.pilote.cadre, ECRAN);
assert.deepEqual(
manifeste.captures.map(({ fichier, source, cadre, pdf }) => ({ fichier, source, cadre, pdf })),
[
{ fichier: '01-premier-ecran', source: 'navigateur', cadre: ECRAN, pdf: null },
{ fichier: '24-badges-controle', source: 'pdf', cadre: enPixels(A4), pdf: sha256(OCTETS) },
],
);
const ecrits = (await empreintesDossier(null, { racine })).map(([chemin]) => chemin);
assert.deepEqual(ecrits, [`${DOSSIER_CAPTURES}/01-premier-ecran.png`, `${DOSSIER_CAPTURES}/24-badges-controle.png`, CHEMIN_MANIFESTE]);
});
});
test('deux captures du navigateur de cadres différents refusent toujours la publication, rien de publié', async (contexte) => {
if (!canevasDisponible()) contexte.skip(SANS_CANEVAS);
await dansUnDossier(async (racine) => {
await deposer(racine, { fichier: '01-premier-ecran', etape: '1', source: 'navigateur', page: 1 });
await deposer(racine, { fichier: '02-import-apercu', etape: '2', source: 'navigateur', page: 2 });
await assert.rejects(
publierCaptures(CONTEXTE, { declarees: ['01-premier-ecran', '02-import-apercu'], racine, racineNeutre: RACINE_NEUTRE }),
/publication : 02-import-apercu n’a pas le cadre ni le facteur d’échelle de 01-premier-ecran/,
);
assert.deepEqual(await empreintesDossier(null, { racine }), []);
});
});
test('une page de PDF seule, sans capture du navigateur, ne donne aucun pilote : refusée, rien de publié', async (contexte) => {
if (!canevasDisponible()) contexte.skip(SANS_CANEVAS);
await dansUnDossier(async (racine) => {
await deposer(racine, { fichier: '24-badges-controle', etape: '12', source: 'pdf', page: 1 });
await assert.rejects(
publierCaptures(CONTEXTE, { declarees: ['24-badges-controle'], racine, racineNeutre: RACINE_NEUTRE }),
/publication : aucune capture du navigateur/,
);
assert.deepEqual(await empreintesDossier(null, { racine }), []);
});
});
});

View file

@ -20,9 +20,10 @@
// captures d'écran (test/navigateur/commandes_documentation.js)
// y sont déclarées, et le mode des captures, que GTT_CAPTURES
// choisit — verifier par défaut, ecrire sous make captures —,
// arrive à la page par provide (§ 19.5). La commande
// d'impression (test/navigateur/commandes_impression.js), qui
// imprime le document d'une épreuve en PDF par le moteur de
// arrive à la page par provide (§ 19.5). Parmi elles, tramerPdf
// trame sous node les pages du PDF de la planche (§ 19.6). La
// commande d'impression (test/navigateur/commandes_impression.js),
// qui imprime le document d'une épreuve en PDF par le moteur de
// Chromium (§ 11.7), y est déclarée aussi.
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';