// © 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 }), []); }); }); });