diff --git a/scripts/essai_demarrage.js b/scripts/essai_demarrage.js index 54d7610..15856f1 100644 --- a/scripts/essai_demarrage.js +++ b/scripts/essai_demarrage.js @@ -8,7 +8,8 @@ // page, que l'application y monte, que la détermination du dossier de // travail retient le mode portable, qu'un travailleur de génération naît et // rend son issue dans la page chargée sous file://, que la police de -// l'interface s'y charge, et que la sortie rend les verrous. +// l'interface s'y charge, que l'aperçu s'y imprime une page par feuille, et +// que la sortie rend les verrous. // // node scripts/essai_demarrage.js // @@ -23,7 +24,11 @@ // qui s'ouvre, revenir à la liste, puis charger la petite // démonstration, qui s'ouvre ; // 3. le mode portable : leurs deux .gtt.json sous exe/data, aucun sous home -// ni sous temp ; +// ni sous temp ; puis « Imprimer… » ouvre l'aperçu, le papier A4 +// choisi, et le processus principal imprime en PDF le plan, puis la +// feuille par table (webContents.printToPDF, à la taille de page de la +// feuille de style) : une page par feuille de l'aperçu, chacune aux +// dimensions de la sienne à TOLERANCE_PAGE_MM près ; l'aperçu se ferme ; // 4. « Modifier » prend un verrou sous exe/data ; le compte d'arrêt passe à // 2 000 mouvements par son champ, puis « Générer », dans la page chargée // sous file://, fait naître un travailleur et ajoute des propositions, @@ -66,8 +71,10 @@ import { constante } from '../src/geometrie/constantes.js'; import { estSous } from '../src/stockage/dossier_travail.js'; import { VERSION } from '../src/version.genere.js'; import { titreAvecVersion } from '../src/interface/libelles.js'; +import { MM_PAR_PX } from '../src/interface/impression/modele.js'; import { lirePolices } from '../test/polices.js'; import { CHAINE_TEMOIN, longueurTemoin } from '../test/temoin.js'; +import { TOLERANCE_PAGE_MM, lireImpression } from '../test/navigateur/commandes_impression.js'; const RACINE = fileURLToPath(new URL('..', import.meta.url)); const ATTENTE_MS = 60_000; @@ -78,6 +85,11 @@ const NOM_EVENEMENT = "Soirée d'essai"; const DEMONSTRATION = 'petite'; const ARRET = 2000; +// Les vues de l'aperçu que la coquille imprime, dans l'ordre : le plan, +// dans l'orientation de sa découpe, puis la feuille par table, en portrait, +// une page au moins par table. +const VUES_IMPRIMEES = ['plan', 'tables']; + // La police de l'interface, ses graisses, et le corps en px où la chaîne // témoin se mesure. const FAMILLE = 'Noto Sans'; @@ -131,6 +143,40 @@ function releverPolice({ chaine, corps }) { return { faces, familleCorps: getComputedStyle(document.body).fontFamily, largeurTemoin }; } +/** + * Les fautes d'une impression de l'aperçu, [] quand elle est conforme : un + * aperçu sans feuille ; un compte de pages qui n'est pas celui des feuilles, + * et rien d'autre alors ; sinon, dans l'ordre des pages, chaque page dont la + * largeur ou la hauteur s'écarte de plus de tolerance de celles de sa + * feuille. Les dimensions sont en mm. + * @param {Array<{largeur: number, hauteur: number}>} feuilles celles de l'aperçu, dans l'ordre + * @param {Array<{largeur: number, hauteur: number}>} pages les boîtes de média du PDF + * @param {number} tolerance + * @returns {string[]} + */ +export function fautesDImpression(feuilles, pages, tolerance) { + if (feuilles.length === 0) return ['aucune feuille dans l’aperçu']; + if (pages.length !== feuilles.length) return [`${pages.length} page(s) pour ${feuilles.length} feuille(s)`]; + return pages.flatMap((page, rang) => { + const feuille = feuilles[rang]; + const ecart = Math.max(Math.abs(page.largeur - feuille.largeur), Math.abs(page.hauteur - feuille.hauteur)); + return ecart <= tolerance + ? [] + : [`page ${rang + 1} : ${page.largeur} × ${page.hauteur} mm pour une feuille de ${feuille.largeur} × ${feuille.hauteur} mm`]; + }); +} + +// Relève dans la page les dimensions rendues de chaque feuille de l'aperçu, +// en mm, dans l'ordre du document ; mmParPx, les millimètres par px CSS, +// vient du processus de l'essai (MM_PAR_PX), la fonction s'exécutant dans +// la page. +function releverFeuilles(mmParPx) { + return [...document.querySelectorAll('[data-impression] [data-feuille]')].map((feuille) => { + const { width, height } = feuille.getBoundingClientRect(); + return { largeur: width * mmParPx, hauteur: height * mmParPx }; + }); +} + /** * Les trois dossiers distincts de l'essai, sous racine. * @param {string} racine @@ -316,6 +362,30 @@ async function seance(application, dossiers) { constater('deux .gtt.json sous exe/data', etats.length === 2, etats.join(', ')); const egares = [dossiers.home, dossiers.temp].flatMap((d) => fichiersSous(d, '.gtt.json').map((f) => join(d, f))); constater('aucun .gtt.json sous home ni temp', egares.length === 0, egares.join(', ') || 'aucun'); + // L'aperçu, imprimé par le processus principal : le plan, puis la + // feuille par table. + await fenetre.locator('[data-ecran="evenement"] [data-commande="apercu"]').click(); + await fenetre.locator('[data-ecran="apercu"] [data-papier="question"] [data-cible="A4"] [data-commande="choisirPapier"]').click(); + for (const vue of VUES_IMPRIMEES) { + await fenetre.locator(`[data-ecran="apercu"] [data-commande="vue"][data-cible="${vue}"]`).click(); + await fenetre.locator(`[data-impression][data-vue-impression="${vue}"] [data-feuille]`).first().waitFor({ timeout: ATTENTE_MS }); + await fenetre.evaluate(() => document.fonts.ready.then(() => undefined)); + const feuilles = await fenetre.evaluate(releverFeuilles, MM_PAR_PX); + const pdf = await application.evaluate(async ({ BrowserWindow }) => { + const octets = await BrowserWindow.getAllWindows()[0].webContents.printToPDF({ preferCSSPageSize: true, printBackground: true }); + return octets.toString('base64'); + }); + const lu = await lireImpression(new Uint8Array(Buffer.from(pdf, 'base64'))); + const fautesImpression = fautesDImpression(feuilles, lu.mediaBox, TOLERANCE_PAGE_MM); + const dimensions = ({ largeur, hauteur }) => `${largeur.toFixed(2)} × ${hauteur.toFixed(2)} mm`; + constater( + `aperçu « ${vue} » imprimé par la coquille : une page par feuille, chacune aux dimensions de la sienne`, + fautesImpression.length === 0, + fautesImpression.length === 0 ? `${lu.pages} page(s) : ${lu.mediaBox.map(dimensions).join(', ')}` : fautesImpression.join(' ; '), + ); + } + await fenetre.locator('[data-ecran="apercu"] [data-commande="fermerApercu"]').click(); + await fenetre.locator('[data-ecran="evenement"]').waitFor({ timeout: ATTENTE_MS }); // 4. « Modifier », le compte d'arrêt, « Générer ». await fenetre.locator('[data-commande="modifier"]').first().click(); diff --git a/scripts/essai_demarrage.test.js b/scripts/essai_demarrage.test.js index 15486b0..d7acd2b 100644 --- a/scripts/essai_demarrage.test.js +++ b/scripts/essai_demarrage.test.js @@ -12,6 +12,10 @@ // temp pour dossier temporaire, et la configuration XDG du poste réel n'y // entre pas. Le module se charge sans rien lancer : l'essai ne part que // lorsque node l'exécute. +// +// L'impression de l'aperçu par la coquille (§ 11.7) : une page du PDF par +// feuille de l'aperçu, chacune aux dimensions de la sienne à la tolérance +// près ; chaque écart se nomme. import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, symlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -25,6 +29,7 @@ import { dossiersDEssai, environnementDEssai, estLanceDirectement, + fautesDImpression, fautesDePolice, } from './essai_demarrage.js'; @@ -255,3 +260,41 @@ describe('essai de démarrage : la police de la page (§ 19.4)', () => { assert.deepEqual(fautesDePolice({ ...RELEVE, largeurTemoin: 0 }, TEMOIN), ['chaîne témoin : 0 px pour 1000 px']); }); }); + +describe('essai de démarrage : l’impression de l’aperçu (§ 11.7)', () => { + // Trois feuilles de 210 × 297 mm et la troisième à plat, et les boîtes de + // média qui leur répondent, chacune décalée de moins que la tolérance. + const FEUILLES = Object.freeze([ + { largeur: 210, hauteur: 297 }, + { largeur: 210, hauteur: 297 }, + { largeur: 297, hauteur: 210 }, + ]); + const PAGES = Object.freeze([ + { largeur: 209.9, hauteur: 297.1 }, + { largeur: 210, hauteur: 297 }, + { largeur: 297.2, hauteur: 209.8 }, + ]); + const TOLERANCE = 0.25; + + test('une page par feuille, chacune aux dimensions de la sienne : aucune faute ; un écart égal à la tolérance est admis', () => { + assert.deepEqual(fautesDImpression(FEUILLES, PAGES, TOLERANCE), []); + const auBord = PAGES.map(({ largeur, hauteur }, rang) => (rang === 0 ? { largeur: 210.25, hauteur: 296.75 } : { largeur, hauteur })); + assert.deepEqual(fautesDImpression(FEUILLES, auBord, TOLERANCE), []); + }); + + test('un compte de pages qui n’est pas celui des feuilles se nomme, et rien d’autre ne se compare', () => { + assert.deepEqual(fautesDImpression(FEUILLES, PAGES.slice(0, 1), TOLERANCE), ['1 page(s) pour 3 feuille(s)']); + assert.deepEqual(fautesDImpression(FEUILLES.slice(0, 1), PAGES, TOLERANCE), ['3 page(s) pour 1 feuille(s)']); + }); + + test('une page hors de la tolérance se nomme par son rang, lue puis attendue ; une orientation inversée aussi', () => { + const large = PAGES.map((page, rang) => (rang === 1 ? { largeur: 210.3, hauteur: 297 } : page)); + assert.deepEqual(fautesDImpression(FEUILLES, large, TOLERANCE), ['page 2 : 210.3 × 297 mm pour une feuille de 210 × 297 mm']); + const debout = PAGES.map((page, rang) => (rang === 2 ? { largeur: 210, hauteur: 297 } : page)); + assert.deepEqual(fautesDImpression(FEUILLES, debout, TOLERANCE), ['page 3 : 210 × 297 mm pour une feuille de 297 × 210 mm']); + }); + + test('un aperçu sans feuille est une faute, même sans page', () => { + assert.deepEqual(fautesDImpression([], [], TOLERANCE), ['aucune feuille dans l’aperçu']); + }); +}); diff --git a/src/interface/impression/FeuillesTables.svelte b/src/interface/impression/FeuillesTables.svelte index ab7cfba..826f4d4 100644 --- a/src/interface/impression/FeuillesTables.svelte +++ b/src/interface/impression/FeuillesTables.svelte @@ -13,22 +13,36 @@ // son tour, et le filigrane de la règle du brouillon à ce tour // (feuilleDuTour). // - // Rien ne se tronque : un nom long replie dans sa colonne (§ 7.4). Chaque - // table commence une page ; une table plus longue qu'une page continue sur - // la suivante, sous son en-tête répété. La pagination se fait sur les - // hauteurs rendues, comme celle de la liste d'accueil : toutes les tables - // se rendent d'abord, hors de la vue, dans une feuille de la bande reçue, - // où se lisent la hauteur utile, et pour chaque table celle de son - // en-tête et de chacune de ses lignes (mesurerListes) ; paginerFeuilles en - // fait les pages. Une soirée sans table imprime une feuille qui le dit. + // Rien ne se tronque : un nom long replie dans sa colonne (§ 7.4). Une + // colonne de tour garde au moins la largeur du plus long mot de ses + // cellules ; les tours d'une feuille qui n'y tiennent pas tous côte à + // côte se scindent en groupes de tours consécutifs, chaque groupe sous ses + // propres têtes de tour (toursParGroupe, grouperTours). Chaque groupe + // commence une page ; un groupe plus long qu'une page continue sur la + // suivante, sous son en-tête répété. Une soirée sans table imprime une + // feuille qui le dit. + // + // Tout se décide sur le rendu, hors de la vue, dans une feuille de la + // bande reçue, en passes successives. La première rend une sonde par + // feuille : un tableau d'une colonne, à la largeur de son contenu sans + // repli dans les mots, qui porte chaque cellule et chaque tête de tour de + // la feuille ; sa part de la largeur (mesurerPlace) donne la taille de ses + // groupes. Les suivantes rendent les groupes et lisent la hauteur utile, + // et pour chaque groupe celle de son en-tête et de chacune de ses lignes + // (mesurerListes). Une ligne encore plus haute qu'une page — un nom fait + // de nombreux mots courts — resserre les groupes de sa feuille + // (resserrerGroupes), et la passe se refait ; sinon paginerFeuilles en + // fait les pages. Une ligne qui passe la page sous un seul tour par + // groupe, sa colonne à toute la largeur que la place laisse, reste seule + // sur sa page. // // La source est celle de l'aperçu, tour par tour (etatsDesTours). // // Sans bandeMm, la vue rend sa feuille gabarit, que l'aperçu mesure. import { getContext, tick } from 'svelte'; import Feuille from './Feuille.svelte'; - import { etatsDesTours, feuilleDuTour, feuillesTables, largeursColonnes } from './listes.js'; - import { mesurerListes, paginerFeuilles } from './pagination.js'; + import { etatsDesTours, feuilleDuTour, feuillesTables, grouperTours, largeursColonnes, resserrerGroupes, toursParGroupe } from './listes.js'; + import { mesurerListes, mesurerPlace, paginerFeuilles } from './pagination.js'; let { etat, options, feuille, bandeMm = null, orientation = $bindable(null) } = $props(); @@ -39,21 +53,43 @@ const etats = $derived(etatsDesTours(charge, etat.proposition)); const listes = $derived(feuillesTables(charge, etats, { parTour: options.parTour })); - // Les mesures de la feuille de mesure, et ce qu'elles mesuraient ; les - // pages, null tant qu'elles ne sont pas celles des listes courantes. + // La taille de groupe de chaque feuille et ce qu'elle mesurait, null + // avant la passe des sondes ; les groupes qui en suivent ; les mesures des + // groupes et ce qu'elles mesuraient ; les pages, null tant qu'elles ne + // sont pas celles des groupes courants. const cle = $derived(JSON.stringify([options.papier, bandeMm, listes])); + let tailles = $state(null); + const parGroupe = $derived(tailles !== null && tailles.cle === cle ? tailles.valeurs : null); + const groupes = $derived(parGroupe === null ? null : grouperTours(listes, parGroupe)); + const cleGroupes = $derived(JSON.stringify([cle, parGroupe])); let mesures = $state(null); - const pages = $derived(mesures !== null && mesures.cle === cle ? paginerFeuilles(mesures.feuilles, mesures.utile) : null); + const pages = $derived(mesures !== null && mesures.cle === cleGroupes ? paginerFeuilles(mesures.feuilles, mesures.utile) : null); const feuilleMesure = $derived({ ...feuille, filigrane: false }); + // Les sondes de la première passe, une par feuille. + let sondes = $state([]); - // Mesure la feuille de mesure une fois le rendu à jour (tick) : la - // clé lue alors est celle des valeurs que le DOM porte. La lecture de - // cle relance la mesure quand elle change avant que les pages viennent. + // Mesure la feuille de mesure une fois le rendu à jour (tick) : la clé + // lue alors est celle des valeurs que le DOM porte. Sans tailles à jour, + // lit les sondes ; avec elles, les hauteurs des groupes, qui donnent les + // pages ou des tailles resserrées. La lecture des clés relance la mesure + // quand l'une change avant que les pages viennent. function mesurer(conteneur) { - const voulue = cle; + const voulue = cleGroupes; + const valeurs = parGroupe; + const feuillesLues = listes; + const groupesLus = groupes; let active = true; tick().then(() => { - if (active && voulue === cle) mesures = { cle, ...mesurerListes(conteneur.querySelector('[data-contenu]')) }; + if (!active || voulue !== cleGroupes) return; + const contenu = conteneur.querySelector('[data-contenu]'); + if (valeurs === null) { + tailles = { cle, valeurs: feuillesLues.map((liste, rang) => toursParGroupe(mesurerPlace(sondes[rang], contenu), liste.tours.length)) }; + return; + } + const lues = mesurerListes(contenu); + const resserrees = resserrerGroupes(valeurs, groupesLus, paginerFeuilles(lues.feuilles, lues.utile)); + if (resserrees === null) mesures = { cle: voulue, ...lues }; + else tailles = { cle, valeurs: resserrees }; }); return () => { active = false; @@ -71,6 +107,41 @@ }); +{#snippet celluleTour(cellule)} + + {#if cellule.nom === null} + {t('feuilles.tables.vide')} + {:else} + {cellule.nom} + {/if} + {#if cellule.titre !== null} + + {cellule.titre.libelle} + {#if !cellule.titre.pourvu} + {t('feuilles.tables.titreNonPourvu')} + {/if} + + {/if} + +{/snippet} + + +{#snippet sonde(liste, rangListe)} + + + {#each liste.tours as tour (tour)} + + {/each} + {#each liste.lignes as ligne (ligne.place)} + {#each ligne.cellules as cellule (cellule.tour)} + {@render celluleTour(cellule)} + {/each} + {/each} + +
{t('feuilles.tour', { tour })}
+{/snippet} + {#snippet blocTable(liste, rangListe, debut, fin)}

{t('feuilles.tables.titre', { table: liste.numero })}

@@ -93,21 +164,7 @@ {ligne.place} {#each ligne.cellules as cellule (cellule.tour)} - - {#if cellule.nom === null} - {t('feuilles.tables.vide')} - {:else} - {cellule.nom} - {/if} - {#if cellule.titre !== null} - - {cellule.titre.libelle} - {#if !cellule.titre.pourvu} - {t('feuilles.tables.titreNonPourvu')} - {/if} - - {/if} - + {@render celluleTour(cellule)} {/each} {/each} @@ -122,9 +179,15 @@ @@ -136,9 +199,9 @@ {:else} {#each pages as page (page.rang)} - + {#snippet contenu()} - {@render blocTable(listes[page.feuille], page.feuille, page.debut, page.fin)} + {@render blocTable(groupes[page.feuille], page.feuille, page.debut, page.fin)} {/snippet} {/each} @@ -190,6 +253,18 @@ font-style: italic; } + /* La sonde prend la largeur de son plus long mot : à la largeur de son + contenu, sans repli dans un mot. */ + .sonde { + width: min-content; + table-layout: auto; + } + + .sonde th, + .sonde td { + overflow-wrap: normal; + } + /* La feuille de mesure se rend hors de la vue : elle se mesure, elle ne se voit ni ne s'atteint. */ .mesure { diff --git a/src/interface/impression/Impression.navigateur.test.js b/src/interface/impression/Impression.navigateur.test.js new file mode 100644 index 0000000..3c7a862 --- /dev/null +++ b/src/interface/impression/Impression.navigateur.test.js @@ -0,0 +1,166 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// L'impression réelle de l'aperçu (§ 11.1, § 11.7, § 14.4) : le document de +// l'épreuve passe par le moteur d'impression de Chromium, qui en fait un +// PDF, et la lecture du PDF dit ce qu'une imprimante recevrait. Une page +// par feuille de l'aperçu, chacune à la boîte de média du papier et de +// l'orientation de sa feuille ; « BROUILLON » dans la couche de texte de +// chaque page d'un brouillon et d'aucune page d'un retenu ; l'horodatage du +// placement sur chaque page ; et cela pour le plan découpé comme pour la +// liste d'accueil et la feuille par table, chacune de plusieurs pages. +// +// La commande imprimerEnPdf (test/navigateur/commandes_impression.js) +// imprime, côté node, le document sérialisé du cadre de l'épreuve dans une +// page neuve, et rend ce qu'elle lit du PDF, jamais ses octets. Le même +// module donne l'écart admis entre une page et son papier, que l'essai de +// démarrage applique aussi ; modele.js donne le millimètre en px CSS. +// +// App est montée par test/navigateur/ecrans.js sur le système de fichiers +// simulé, au cadre de 1 366 × 700 ; le texte attendu vient de la même +// traduction que celle de l'application. Le projet navigateur emploie +// expect, et test et describe de Vitest (§ 14.8). +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { commands } from 'vitest/browser'; +import { appliquerCommande } from '../../application/commandes.js'; +import { creerFormat } from '../../application/format.js'; +import { TABLES_APPLICATION } from '../../application/libelles.js'; +import { creerTraduction } from '../../application/traduction.js'; +import { texteLivre } from '../../demo/livrees.js'; +import { rechercher } from '../../moteur/recherche.js'; +import { analyser, configurationDepuisCharge } from '../../stockage/document.js'; +import { VERSION } from '../../version.genere.js'; +import { TOLERANCE_PAGE_MM } from '../../../test/navigateur/commandes_impression.js'; +import { fixerCadre } from '../../../test/navigateur/entrees.js'; +import { attendre, cliquer, creerContexte, ouvrirApercu } from '../../../test/navigateur/ecrans.js'; +import { TABLES_INTERFACE } from '../libelles.js'; +import { MM_PAR_PX, dimensionsPage } from './modele.js'; + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); +const format = creerFormat(t); + +// Images attendues au plus par étape : bien au-delà d'un rendu, sous le +// délai d'une épreuve. +const IMAGES = 300; + +// L'horodatage posé sur les placements des épreuves de la couche de texte. +const HORODATAGE = '2031-05-17T19:30:00-04:00'; + +let contexte = null; +beforeEach(async () => { + await fixerCadre(1366, 700); + contexte = creerContexte(); +}); +afterEach(async () => { + const erreurs = contexte.erreurs(); + await contexte.defaire(); + expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]); +}); + +const dans = (m, selecteur) => m.racine.querySelector(selecteur); +const feuilles = (m) => [...m.racine.querySelectorAll('[data-impression] [data-feuille]')]; +const sansBlancs = (chaine) => chaine.replace(/\s+/g, ''); + +// Les dimensions rendues d'une feuille, en mm, et l'orientation qu'elles +// disent. +function dimensionsFeuille(feuille) { + const { width, height } = feuille.getBoundingClientRect(); + const largeur = width * MM_PAR_PX; + const hauteur = height * MM_PAR_PX; + return { largeur, hauteur, orientation: largeur > hauteur ? 'paysage' : 'portrait' }; +} + +// Change la découpe du plan par son sélecteur, et attend le document. +async function decouper(m, decoupe) { + const choix = dans(m, '[data-ecran="apercu"] [data-champ="decoupe"]'); + choix.value = decoupe; + choix.dispatchEvent(new Event('change', { bubbles: true })); + await attendre(`la découpe ${decoupe}`, () => m.ui.etat().apercu.decoupe === decoupe && feuilles(m).length > 0, IMAGES); +} + +// Choisit une vue de l'aperçu par sa commande, et attend son document. +async function choisirVue(m, vue) { + await cliquer(m, `[data-ecran="apercu"] [data-commande="vue"][data-cible="${vue}"]`); + await attendre(`la vue ${vue}`, () => dans(m, `[data-impression][data-vue-impression="${vue}"] [data-feuille]`), IMAGES); +} + +// La grande démonstration et une proposition de la recherche, tirée de la +// graine 1 : 260 personnes, 33 tables, quatre tours ; le plan est proposé. +// Le plan découpé, la liste d'accueil et la feuille par table y passent +// chacun la page. +function grandeProposee() { + const charge = analyser(texteLivre('grande')).charge; + const configuration = configurationDepuisCharge(charge); + const propositions = rechercher(configuration, { graine: 1, nombre: 1, arret: 1, historique: 10 }); + return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee }).charge; +} + +// La grande proposée, sa proposition retenue, puis le plan bloqué. +function grandeBloquee() { + const proposee = grandeProposee(); + const retenue = appliquerCommande('retenirProposition', proposee, { id: proposee.propositions[0].id }).charge; + return appliquerCommande('changerEtat', retenue, { etat: 'bloque' }).charge; +} + +// Une charge dont le retenu et chaque proposition portent HORODATAGE. +function avecHorodatage(charge) { + const datee = structuredClone(charge); + for (const proposition of datee.propositions) proposition.horodatage = HORODATAGE; + if (datee.retenu !== null) datee.retenu.horodatage = HORODATAGE; + return datee; +} + +// Imprime le document de l'épreuve et vérifie une page par feuille, chacune +// à la boîte de média du papier et de l'orientation de sa feuille, elle-même +// aux dimensions de ce papier. Rend le PDF lu. +async function imprimerEtCompter(m, papier) { + const lues = feuilles(m).map(dimensionsFeuille); + expect(lues.length).toBeGreaterThan(0); + const pdf = await commands.imprimerEnPdf(); + expect(pdf.pages, 'une page par feuille').toBe(lues.length); + expect(pdf.mediaBox).toHaveLength(lues.length); + pdf.mediaBox.forEach((boite, rang) => { + const attendue = dimensionsPage(papier, lues[rang].orientation); + for (const cote of ['largeur', 'hauteur']) { + expect(Math.abs(lues[rang][cote] - attendue[cote]), `feuille ${rang + 1}, ${cote}`).toBeLessThanOrEqual(0.01); + expect(Math.abs(boite[cote] - attendue[cote]), `page ${rang + 1}, ${cote} : ${boite[cote]} mm`).toBeLessThanOrEqual(TOLERANCE_PAGE_MM); + } + }); + return pdf; +} + +describe('le PDF de Chromium : une page par feuille, au papier de la feuille (§ 11.7)', () => { + for (const papier of ['A4', 'Lettre']) { + test(`le plan découpé de la grande démonstration, ${papier} : autant de pages que de feuilles, chacune au papier et à l’orientation de la sienne`, async () => { + const m = await ouvrirApercu(contexte, analyser(texteLivre('grande')).charge, { papier, section: 'participants' }); + await decouper(m, 'decoupe'); + expect(feuilles(m).length, 'un plan découpé en plusieurs pages').toBeGreaterThan(1); + await imprimerEtCompter(m, papier); + }); + } +}); + +describe('la couche de texte du PDF : filigrane et horodatage sur chaque page (§ 11.7)', () => { + for (const [cas, chargeDuCas, brouillon] of [ + ['la grande démonstration proposée', grandeProposee, true], + ['la grande démonstration bloquée', grandeBloquee, false], + ]) { + test(`${cas} : plan découpé, accueil et tables en plusieurs pages ; « BROUILLON » ${brouillon ? 'sur chacune' : 'sur aucune'}, l’horodatage sur chacune`, async () => { + const m = await ouvrirApercu(contexte, avecHorodatage(chargeDuCas())); + const filigrane = sansBlancs(t('impression.filigrane')); + const horodatage = sansBlancs(format.horodatage(HORODATAGE)); + for (const vue of ['plan', 'accueil', 'tables']) { + await choisirVue(m, vue); + if (vue === 'plan') await decouper(m, 'decoupe'); + expect(feuilles(m).length, `${vue} : plusieurs pages`).toBeGreaterThan(1); + for (const feuille of feuilles(m)) expect(sansBlancs(feuille.querySelector('[data-pied]').textContent), `${vue} : le pied à l’écran`).toContain(horodatage); + const pdf = await imprimerEtCompter(m, 'A4'); + pdf.textes.forEach((texte, rang) => { + const page = sansBlancs(texte); + expect(page.includes(filigrane), `${vue}, page ${rang + 1} : « ${t('impression.filigrane')} »`).toBe(brouillon); + expect(page, `${vue}, page ${rang + 1} : l’horodatage du placement`).toContain(horodatage); + }); + } + }); + } +}); diff --git a/src/interface/impression/Listes.navigateur.test.js b/src/interface/impression/Listes.navigateur.test.js index 5a763cf..1ea7b3d 100644 --- a/src/interface/impression/Listes.navigateur.test.js +++ b/src/interface/impression/Listes.navigateur.test.js @@ -12,7 +12,11 @@ // nommés, « non pourvu » quand rien ne les pourvoit ; « Une feuille par // tour » donne une feuille par table et par tour, chacune au pied et au // filigrane de son tour, sans rien écrire, en lecture comme sur un plan -// bloqué (§ 8.4). +// bloqué (§ 8.4). Sur la feuille par table, une colonne de tour garde la +// largeur de son plus long mot ; une soirée dont les tours n'y tiennent +// pas, ou dont une ligne passerait la hauteur d'une page, se scinde en +// groupes de tours, chaque groupe sous ses têtes de tour, chaque tour une +// fois par table, et aucune ligne ne déborde de sa page. // // App est montée par test/navigateur/ecrans.js sur le système de fichiers // simulé, au cadre de 1 366 × 700 ; le texte attendu vient de la même @@ -44,6 +48,10 @@ const IMAGES = 300; // repli au caractère. const NOM_LONG = 'Ombrelle'.repeat(25); +// Un nom long fait de mots courts : le repli entre les mots suffit à la +// largeur, et la hauteur de la ligne croît avec le nombre de mots. +const NOM_LONG_DE_MOTS = 'Ombrelle Pervenche '.repeat(40).trim(); + // La marque d'abréviation du plan (§ 7.4), qu'aucune de ces vues ne pose. const ABREVIATION = '\u{2026}'; @@ -205,7 +213,9 @@ describe('aucune troncature : les listes imprimées replient (§ 7.4)', () => { const longs = noms.filter((noeud) => noeud.textContent === attendu); expect(longs.length, 'le nom entier à chaque tour où la personne est assise').toBeGreaterThan(0); const ligne = longs[0].closest('[data-ligne-imprimee]'); - const autre = noms.find((noeud) => noeud.closest('[data-ligne-imprimee]') !== ligne && noeud.closest('[data-liste-imprimee]') !== longs[0].closest('[data-liste-imprimee]')); + // Une autre table : ses lignes ne portent pas le nom long, à aucun tour. + const tableDuNom = longs[0].closest('[data-liste-imprimee]').dataset.table; + const autre = noms.find((noeud) => noeud.closest('[data-liste-imprimee]').dataset.table !== tableDuNom); expect(boite(ligne).height, 'la ligne du nom long replie').toBeGreaterThan(1.5 * boite(autre.closest('[data-ligne-imprimee]')).height); expect(dans(m, '[data-impression]').textContent).not.toContain(ABREVIATION); }); @@ -336,3 +346,67 @@ describe('la feuille par table (§ 11.7, Q6 c)', () => { }); }); }); + +describe('la feuille par table : des tours nombreux sous un nom long (§ 7.4)', () => { + // Les groupes de tours de chaque table, dans l'ordre du document : pour + // chaque table, les têtes de tour de chacune de ses listes, une liste par + // groupe — une liste plus longue qu'une page reprend ses têtes sur la + // suivante, sous le même rang. + function groupesParTable(m) { + const groupes = new Map(); + const vues = new Set(); + for (const bloc of tous(m.racine, '[data-impression] [data-liste-imprimee]')) { + if (vues.has(bloc.dataset.listeImprimee)) continue; + vues.add(bloc.dataset.listeImprimee); + const table = Number(bloc.dataset.table); + if (!groupes.has(table)) groupes.set(table, []); + groupes.get(table).push([...bloc.querySelector('[data-entete-liste] tr').cells].slice(1).map(texte)); + } + return groupes; + } + + for (const [cas, nom, tours] of [ + ['un nom de deux cents caractères sans espace, dix tours', NOM_LONG, 10], + ['un nom de sept cents caractères en mots courts, quinze tours', NOM_LONG_DE_MOTS, 15], + ]) { + test(`${cas}, A4 : chaque ligne tient dans sa page ; la table du nom se scinde en groupes de tours, chaque tour une fois`, async () => { + const charge = appliquerCommande( + 'changerTours', + appliquerCommande('modifierParticipant', chargePrincipale(), { id: 1, champs: { nom } }).charge, + { tours }, + ).charge; + const m = await ouvrirApercu(contexte, charge); + await choisirVue(m, 'tables'); + verifierPagination(m); + const attendues = Array.from({ length: tours }, (_, rang) => t('feuilles.tour', { tour: rang + 1 })); + const groupes = groupesParTable(m); + expect([...groupes.keys()].sort((a, b) => a - b)).toEqual(charge.tables.map(({ id }) => id).sort((a, b) => a - b)); + for (const [table, suite] of groupes) expect(suite.flat(), `table ${table} : chaque tour une fois, dans l’ordre`).toEqual(attendues); + const nomAttendu = nomAffiche(charge.participants.find(({ id }) => id === 1)); + const tableDuNom = Number( + tous(m.racine, '[data-impression] [data-nom-imprime]').find((noeud) => noeud.textContent === nomAttendu).closest('[data-liste-imprimee]').dataset.table, + ); + expect(groupes.get(tableDuNom).length, 'la table du nom long en plusieurs groupes de tours').toBeGreaterThan(1); + expect(dans(m, '[data-impression]').textContent).not.toContain(ABREVIATION); + }); + } + + test('dix tours et des noms ordinaires : aucun mot coupé dans une colonne de tour, chaque tour une fois par table', async () => { + const charge = appliquerCommande('changerTours', chargePrincipale(), { tours: 10 }).charge; + const m = await ouvrirApercu(contexte, charge); + await choisirVue(m, 'tables'); + verifierPagination(m); + const attendues = Array.from({ length: 10 }, (_, rang) => t('feuilles.tour', { tour: rang + 1 })); + for (const [table, suite] of groupesParTable(m)) expect(suite.flat(), `table ${table}`).toEqual(attendues); + const cellules = tous(m.racine, '[data-impression] [data-liste-imprimee] tr').flatMap((rangee) => [...rangee.cells].slice(1)); + expect(cellules.flatMap(motsCoupes), 'mots coupés').toEqual([]); + }); + + test('quatre tours et des noms ordinaires : une seule feuille par table, tous les tours en colonnes', async () => { + const charge = chargePrincipale(); + const m = await ouvrirApercu(contexte, charge); + await choisirVue(m, 'tables'); + const attendues = Array.from({ length: charge.evenement.tours }, (_, rang) => t('feuilles.tour', { tour: rang + 1 })); + for (const [table, suite] of groupesParTable(m)) expect(suite, `table ${table}`).toEqual([attendues]); + }); +}); diff --git a/src/interface/impression/listes.js b/src/interface/impression/listes.js index 75de560..31abd1b 100644 --- a/src/interface/impression/listes.js +++ b/src/interface/impression/listes.js @@ -219,3 +219,102 @@ export function largeursColonnes(vue, tours, partTour = null) { const noms = 100 - tours * tour; return [...fixes.map((part) => (part * noms) / somme), ...Array.from({ length: tours }, () => tour)]; } + +// La part de la largeur que les colonnes de tour de la feuille par table +// se partagent, en pour cent : ce que le numéro de la place laisse. +const PART_TOURS_TABLES = 100 - COLONNES_FIXES.get('tables').reduce((a, b) => a + b, 0); + +/** + * Le nombre de colonnes de tour qu'une feuille par table garde côte à côte + * quand chacune doit prendre au moins part de la largeur de la liste — la + * part de son plus long mot (mesurerPlace) : le plus grand n de 1 à tours + * dont les n colonnes, à parts égales de ce que la place laisse + * (largeursColonnes), en reçoivent chacune au moins part. Un mot plus large + * que toute cette part donne 1 : il replie dans la plus large colonne + * possible. + * + * @param {number} part en pour cent, finie, > 0 + * @param {number} tours le nombre de tours de la feuille, entier ≥ 1 + * @returns {number} + * @throws {RangeError} une part ou un nombre de tours hors du domaine + */ +export function toursParGroupe(part, tours) { + if (typeof part !== 'number' || !Number.isFinite(part) || part <= 0) { + throw new RangeError(`toursParGroupe : part finie > 0 attendue, reçu ${String(part)}`); + } + if (!Number.isSafeInteger(tours) || tours < 1) throw new RangeError(`toursParGroupe : tours entier ≥ 1 attendu, reçu ${String(tours)}`); + return Math.max(1, Math.min(tours, Math.floor((PART_TOURS_TABLES + BRUIT) / part))); +} + +/** + * Les feuilles par table, chacune scindée en groupes de tours consécutifs + * (§ 7.4) : la feuille de rang i en ⌈T / parGroupe[i]⌉ groupes, T ses tours, + * de tailles égales à un près, les plus grands d'abord — dix tours par six + * donnent deux groupes de cinq. Chaque groupe est une feuille à part + * entière : la table, son numéro, ses tours, et chaque place avec les + * cellules de ces tours seuls ; source est le rang de la feuille d'où il + * vient. Une feuille qui garde tous ses tours donne un seul groupe, égal à + * elle. + * + * @param {ReturnType} feuilles + * @param {number[]} parGroupe un par feuille, entiers ≥ 1 + * @returns {Array[number] & {source: number}>} + * @throws {RangeError} un compte qui n'est pas celui des feuilles ; une + * taille de groupe hors du domaine + */ +export function grouperTours(feuilles, parGroupe) { + if (!Array.isArray(parGroupe) || parGroupe.length !== feuilles.length) { + throw new RangeError(`grouperTours : une taille de groupe par feuille attendue, ${feuilles.length} feuille(s)`); + } + return feuilles.flatMap((feuille, source) => { + const taille = parGroupe[source]; + if (!Number.isSafeInteger(taille) || taille < 1) throw new RangeError(`grouperTours : taille ${String(taille)} pour la feuille ${source}`); + const total = feuille.tours.length; + const groupes = Math.max(1, Math.ceil(total / taille)); + const petite = Math.floor(total / groupes); + const grandes = total % groupes; + let debut = 0; + return Array.from({ length: groupes }, (_, rang) => { + const fin = debut + petite + (rang < grandes ? 1 : 0); + const groupe = { + table: feuille.table, + numero: feuille.numero, + tours: feuille.tours.slice(debut, fin), + lignes: feuille.lignes.map(({ place, cellules }) => ({ place, cellules: cellules.slice(debut, fin) })), + source, + }; + debut = fin; + return groupe; + }); + }); +} + +/** + * Les tailles de groupe resserrées d'après la pagination (§ 7.4) : une + * feuille dont un groupe porte une page seule — une ligne plus haute que la + * page (paginer) — et dont un groupe porte plus d'un tour voit sa taille + * ramenée à la moitié, arrondie au-dessus, de son plus grand groupe : ses + * colonnes s'élargissent, ses lignes raccourcissent. null quand aucune + * taille ne change : les groupes sont ceux que l'on imprime. + * + * @param {number[]} parGroupe la taille de chaque feuille + * @param {ReturnType} groupes grouperTours(feuilles, parGroupe) + * @param {Array<{feuille: number, seule: boolean}>} pages paginerFeuilles des groupes + * @returns {number[]|null} + */ +export function resserrerGroupes(parGroupe, groupes, pages) { + const suivants = [...parGroupe]; + let change = false; + for (const { feuille, seule } of pages) { + if (!seule) continue; + const { source } = groupes[feuille]; + const plusGrand = Math.max(...groupes.filter((groupe) => groupe.source === source).map(({ tours }) => tours.length)); + if (plusGrand === 1) continue; + const resserre = Math.ceil(plusGrand / 2); + if (resserre < suivants[source]) { + suivants[source] = resserre; + change = true; + } + } + return change ? suivants : null; +} diff --git a/src/interface/impression/listes.test.js b/src/interface/impression/listes.test.js index 9838225..3a72687 100644 --- a/src/interface/impression/listes.test.js +++ b/src/interface/impression/listes.test.js @@ -5,7 +5,9 @@ // § 11.4, § 7.4, § 15.5), sans DOM : l'ordre de la liste d'accueil par la clé // normalisée injectée, jamais localeCompare ; ce que chaque ligne dit de // chaque tour ; les feuilles par table, les tours en colonnes ou une feuille -// par tour (Q6 c) ; le pied et le filigrane d'une feuille d'un seul tour. +// par tour (Q6 c) ; le pied et le filigrane d'une feuille d'un seul tour ; +// les groupes de tours de la feuille par table, leur taille tirée de la +// part mesurée d'un tour, et leur resserrement sous une page seule. // Les noms d'épreuve sont inventés, hors du réservoir des démonstrations. import assert from 'node:assert/strict'; import { describe, test } from '../../../test/lanceur.js'; @@ -19,9 +21,12 @@ import { etatsDesTours, feuilleDuTour, feuillesTables, + grouperTours, largeursColonnes, libellePlace, lignesAccueil, + resserrerGroupes, + toursParGroupe, } from './listes.js'; const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); @@ -328,3 +333,74 @@ describe('largeursColonnes : les colonnes d’une liste imprimée, en pour cent' assert.throws(() => largeursColonnes('tables', 2, 10), RangeError); }); }); + +describe('les groupes de tours de la feuille par table', () => { + // Une feuille de deux places et de tours tours, numérotés de 1 ; chaque + // cellule porte son tour et sa place. + const feuilleDeTours = (table, tours) => ({ + table, + numero: table, + tours: Array.from({ length: tours }, (_, rang) => rang + 1), + lignes: [1, 2].map((place) => ({ + place, + cellules: Array.from({ length: tours }, (_, rang) => ({ tour: rang + 1, occupant: place, nom: `n${place}`, titre: null })), + })), + }); + + test('toursParGroupe : autant de colonnes que la part de la place en laisse à chacune, entre 1 et les tours', () => { + assert.equal(toursParGroupe(22, 4), 4); + assert.equal(toursParGroupe(22, 10), 4); + assert.equal(toursParGroupe(88 / 6, 10), 6); + assert.equal(toursParGroupe(14.7, 10), 5); + assert.equal(toursParGroupe(89, 10), 1); + assert.equal(toursParGroupe(500, 3), 1); + assert.equal(toursParGroupe(1, 3), 3); + for (const part of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, '10']) assert.throws(() => toursParGroupe(part, 3), RangeError, String(part)); + for (const tours of [0, 1.5, Number.NaN]) assert.throws(() => toursParGroupe(10, tours), RangeError, String(tours)); + }); + + test('grouperTours : des groupes consécutifs, de tailles égales à un près, les plus grands d’abord ; chaque cellule suit son tour', () => { + const feuilles = [feuilleDeTours(1, 10), feuilleDeTours(2, 7), feuilleDeTours(3, 4)]; + const groupes = grouperTours(feuilles, [6, 3, 4]); + assert.deepEqual( + groupes.map(({ table, tours, source }) => [table, tours, source]), + [ + [1, [1, 2, 3, 4, 5], 0], + [1, [6, 7, 8, 9, 10], 0], + [2, [1, 2, 3], 1], + [2, [4, 5], 1], + [2, [6, 7], 1], + [3, [1, 2, 3, 4], 2], + ], + ); + for (const groupe of groupes) { + for (const { place, cellules } of groupe.lignes) { + assert.deepEqual( + cellules.map(({ tour }) => tour), + groupe.tours, + `table ${groupe.table}, place ${place}`, + ); + } + } + assert.deepEqual( + grouperTours(feuilles, [1, 7, 9]).map(({ tours }) => tours.length), + [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 7, 4], + ); + assert.throws(() => grouperTours(feuilles, [1, 2]), RangeError, 'un compte autre'); + assert.throws(() => grouperTours(feuilles, [1, 0, 2]), RangeError, 'une taille nulle'); + }); + + test('resserrerGroupes : une page seule ramène sa feuille à la moitié de son plus grand groupe ; un groupe d’un tour reste ; rien de seul, null', () => { + const feuilles = [feuilleDeTours(1, 10), feuilleDeTours(2, 10)]; + const parGroupe = [5, 5]; + const groupes = grouperTours(feuilles, parGroupe); + const pages = groupes.map((_, feuille) => ({ feuille, seule: false })); + assert.equal(resserrerGroupes(parGroupe, groupes, pages), null); + const seule = pages.map((page) => (page.feuille === 3 ? { ...page, seule: true } : page)); + assert.deepEqual(resserrerGroupes(parGroupe, groupes, seule), [5, 3]); + const unParGroupe = grouperTours(feuilles, [1, 5]); + const seuleDUnTour = unParGroupe.map((_, feuille) => ({ feuille, seule: feuille === 0 })); + assert.equal(resserrerGroupes([1, 5], unParGroupe, seuleDUnTour), null); + assert.deepEqual(resserrerGroupes([1, 5], unParGroupe, [...seuleDUnTour, { feuille: unParGroupe.length - 1, seule: true }]), [1, 3]); + }); +}); diff --git a/src/interface/impression/pagination.js b/src/interface/impression/pagination.js index e6a8c45..97e5b96 100644 --- a/src/interface/impression/pagination.js +++ b/src/interface/impression/pagination.js @@ -102,19 +102,20 @@ export function mesurerListes(contenu) { return { utile: contenu.getBoundingClientRect().height, feuilles }; } -// Ce qu'une colonne de tour reçoit au-delà de la largeur mesurée de son -// libellé, en px : les largeurs en pour cent s'arrondissent à l'unité de +// Ce qu'une colonne de tour reçoit au-delà de la largeur mesurée de sa +// sonde, en px : les largeurs en pour cent s'arrondissent à l'unité de // mise en page, et une colonne plus étroite d'une fraction de pixel que son // texte le replie. const ARRONDI_PX = 1; /** - * La part de la largeur d'une liste que prend, sur une ligne, son plus - * large libellé de colonne de tour, en pour cent (largeursColonnes) : la - * largeur rendue de la sonde — un tableau d'une colonne, à la largeur de - * son contenu sans repli, qui porte chaque libellé de place et chaque tête - * de tour dans les cellules de la liste —, plus ARRONDI_PX, sur celle du - * tableau de la liste. + * La part de la largeur d'une liste que prend sa sonde, en pour cent : la + * largeur rendue de la sonde, plus ARRONDI_PX, sur celle du tableau. La + * sonde est un tableau d'une colonne, à la largeur de son contenu, qui + * porte dans les cellules de la liste ce qu'une colonne de tour doit tenir : + * sur une ligne, chaque libellé de place et chaque tête de tour de la liste + * d'accueil (largeursColonnes) ; le plus long mot de chaque cellule et de + * chaque tête de tour d'une feuille par table (toursParGroupe). * * @param {Element} sonde * @param {Element} tableau le tableau de la liste, à sa largeur d'impression diff --git a/test/chromium.test.js b/test/chromium.test.js index a9d361d..5cee6dd 100644 --- a/test/chromium.test.js +++ b/test/chromium.test.js @@ -6,8 +6,8 @@ // sont pas UTF-8, il reçoit l'environnement sous LANG=C.UTF-8. Le projet // navigateur le lance sans réseau hors de la boucle locale, au facteur // d'échelle 1 (§ 19.2, § 19.4), et lui donne les commandes et le mode des -// captures d'écran (§ 19.5). vitest.config.js applique la règle ; le Makefile -// lance la publication. +// captures d'écran (§ 19.5), et la commande d'impression (§ 11.7). +// vitest.config.js applique la règle ; le Makefile lance la publication. import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -16,6 +16,7 @@ import configuration from '../vitest.config.js'; import { ARGUMENTS_NAVIGATEUR, horsBoucle, optionsChromium, optionsNavigateur, suivreRequetes } from './chromium.js'; import { describe, test } from './lanceur.js'; import * as commandesDocumentation from './navigateur/commandes_documentation.js'; +import * as commandesImpression from './navigateur/commandes_impression.js'; const RACINE = fileURLToPath(new URL('..', import.meta.url)); @@ -185,15 +186,21 @@ describe('le projet navigateur : les commandes et le mode des captures d’écra assert.throws(() => commandesDocumentation.modeCaptures({ GTT_CAPTURES: 'ecrir' }), /GTT_CAPTURES.*« ecrir »/); }); - test('vitest.config.js fournit le mode à la page par provide, et déclare chaque commande de commandes_documentation.js', () => { + test('vitest.config.js fournit le mode à la page par provide, et déclare chaque commande de commandes_documentation.js et de commandes_impression.js, et elles seules', () => { assert.deepEqual(navigateur.test.provide, { captures: { mode: commandesDocumentation.modeCaptures(process.env) } }); const declarees = navigateur.test.browser.commands; - assert.deepEqual(Object.keys(declarees).sort(), [...commandesDocumentation.COMMANDES].sort()); - for (const nom of commandesDocumentation.COMMANDES) { - assert.equal(typeof commandesDocumentation[nom], 'function', nom); - assert.equal(declarees[nom], commandesDocumentation[nom], nom); + const modules = [commandesDocumentation, commandesImpression]; + const noms = modules.flatMap(({ COMMANDES }) => COMMANDES); + assert.equal(new Set(noms).size, noms.length, 'un nom de commande dans deux modules'); + assert.deepEqual(Object.keys(declarees).sort(), [...noms].sort()); + for (const module of modules) { + assert.ok(module.COMMANDES.length > 0); + for (const nom of module.COMMANDES) { + assert.equal(typeof module[nom], 'function', nom); + assert.equal(declarees[nom], module[nom], nom); + } } - assert.ok(commandesDocumentation.COMMANDES.length > 0); + assert.deepEqual(commandesImpression.COMMANDES, ['imprimerEnPdf']); }); test('make captures lance le parcours du projet navigateur sous GTT_CAPTURES=ecrire, par avec_node ; .gitignore tait le dossier d’attente', () => { diff --git a/test/navigateur/commandes_impression.js b/test/navigateur/commandes_impression.js new file mode 100644 index 0000000..d63dbaa --- /dev/null +++ b/test/navigateur/commandes_impression.js @@ -0,0 +1,100 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La commande d'impression des épreuves du navigateur, côté node (§ 11.7, +// § 14.4) : vitest.config.js la déclare dans browser.commands, et la page +// l'appelle par commands de vitest/browser. Elle reçoit d'abord le contexte +// de la commande — la page Playwright, le cadre de l'épreuve — et rend du +// JSON, jamais d'octets : le RPC des commandes sérialiserait un tableau +// typé en objet {"0": …}. +// +// Le moteur d'impression de Chromium ne vise qu'une page entière, jamais le +// cadre où l'épreuve s'exécute. imprimerEnPdf sérialise donc le document du +// cadre — ses feuilles de style relues de leurs règles, ses scripts +// retirés, une base qui garde l'adresse des polices et des images — et +// l'imprime dans une page neuve du même contexte, sous les médias +// d'impression, à la taille de page que demande la feuille de style +// (preferCSSPageSize). La lecture du PDF (test/pdf.js, chargé à la première +// impression : la configuration du lanceur importe ce module sans lui) +// rend le nombre de pages, la boîte de média de chacune et sa couche de +// texte. + +/** Les commandes que vitest.config.js déclare, toutes exportées ici. */ +export const COMMANDES = Object.freeze(['imprimerEnPdf']); + +/** L'écart admis entre la boîte de média d'un PDF de Chromium et le papier + * demandé, en mm : Chromium arrondit la taille de page à l'unité de son + * moteur, et l'A4 en sort à 209,89 × 297,01 mm. L'épreuve du navigateur + * et l'essai de démarrage la lisent toutes deux d'ici. */ +export const TOLERANCE_PAGE_MM = 0.25; + +/** + * Ce qu'un PDF imprimé donne à lire : le nombre de pages ; la boîte de + * média de chaque page, en mm ; le texte de chaque page, ses fragments + * dans l'ordre du flux, séparés d'une espace. + * + * @param {Uint8Array} octets + * @returns {Promise<{pages: number, mediaBox: Array<{largeur: number, hauteur: number}>, textes: string[]}>} + */ +export async function lireImpression(octets) { + const { lirePdf } = await import('../pdf.js'); + const lu = await lirePdf(octets); + return { + pages: lu.pages.length, + mediaBox: lu.pages.map(({ boite }) => ({ largeur: boite.largeur, hauteur: boite.hauteur })), + textes: lu.pages.map(({ textes }) => textes.map(({ texte }) => texte).join(' ')), + }; +} + +// Le document du cadre, tel qu'une page neuve le rend sans rien exécuter, +// sous la forme d'un texte HTML. S'exécute dans le cadre : chaque feuille +// de style s'écrit de ses règles — un