[ADD] print: real PDF output proven, table sheets split by tour groups

Printing goes through Chromium's PDF with the CSS page size; a browser
command reads the PDF under node and returns pages, MediaBox and text
only. The tests prove pages = sheets for the cut plan in A4 and Letter,
the paper size within 0.25 mm, BROUILLON and the placement timestamp on
every page of a draft and none of a retained plan. The startup trial
prints the preview through the real shell. A table sheet whose tours do
not fit side by side splits them into groups of consecutive tours, so a
long name wraps inside its page instead of being clipped.
Checked: node 3776, browser 753, node-long 101; startup trial conforms.

--- FR ---

[ADD] impression : PDF réel prouvé, feuilles par table en groupes

L'impression passe par le PDF de Chromium à la taille de page CSS ; une
commande lit le PDF sous node et ne rend que pages, MediaBox et textes.
Les épreuves prouvent pages = feuilles du plan découpé en A4 et Lettre,
le papier à 0,25 mm, BROUILLON et l'horodatage du placement sur chaque
page d'un brouillon et sur aucune d'un retenu. L'essai de démarrage
imprime l'aperçu par la vraie coquille. Une feuille par table dont les
tours ne tiennent pas côte à côte les répartit en groupes consécutifs :
un long nom passe à la ligne dans sa page au lieu d'être coupé.
Vérifié : node 3776, navigateur 753, longues 101 ; essai conforme.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-10 13:33:35 -04:00
parent e71879e852
commit 365561272e
11 changed files with 782 additions and 61 deletions

View file

@ -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();

View file

@ -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']);
});
});

View file

@ -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 @@
});
</script>
{#snippet celluleTour(cellule)}
<td>
{#if cellule.nom === null}
<span class="vide" data-place-vide>{t('feuilles.tables.vide')}</span>
{:else}
<span class="nom" data-nom-imprime>{cellule.nom}</span>
{/if}
{#if cellule.titre !== null}
<span class="titre-place">
<span class="libelle-titre">{cellule.titre.libelle}</span>
{#if !cellule.titre.pourvu}
<span>{t('feuilles.tables.titreNonPourvu')}</span>
{/if}
</span>
{/if}
</td>
{/snippet}
<!-- La sonde d'une feuille : ses têtes de tour et chacune de ses cellules,
une par rangée, dans une seule colonne. -->
{#snippet sonde(liste, rangListe)}
<table class="sonde" bind:this={sondes[rangListe]}>
<tbody>
{#each liste.tours as tour (tour)}
<tr><th>{t('feuilles.tour', { tour })}</th></tr>
{/each}
{#each liste.lignes as ligne (ligne.place)}
{#each ligne.cellules as cellule (cellule.tour)}
<tr>{@render celluleTour(cellule)}</tr>
{/each}
{/each}
</tbody>
</table>
{/snippet}
{#snippet blocTable(liste, rangListe, debut, fin)}
<div class="liste" data-liste-imprimee={rangListe} data-table={liste.table}>
<p class="titre">{t('feuilles.tables.titre', { table: liste.numero })}</p>
@ -93,21 +164,7 @@
<tr data-ligne-imprimee={debut + rang}>
<th scope="row">{ligne.place}</th>
{#each ligne.cellules as cellule (cellule.tour)}
<td>
{#if cellule.nom === null}
<span class="vide" data-place-vide>{t('feuilles.tables.vide')}</span>
{:else}
<span class="nom" data-nom-imprime>{cellule.nom}</span>
{/if}
{#if cellule.titre !== null}
<span class="titre-place">
<span class="libelle-titre">{cellule.titre.libelle}</span>
{#if !cellule.titre.pourvu}
<span>{t('feuilles.tables.titreNonPourvu')}</span>
{/if}
</span>
{/if}
</td>
{@render celluleTour(cellule)}
{/each}
</tr>
{/each}
@ -122,9 +179,15 @@
<div class="mesure" aria-hidden="true" {@attach mesurer}>
<Feuille papier={options.papier} orientation="portrait" feuille={feuilleMesure} {bandeMm}>
{#snippet contenu()}
{#each listes as liste, rangListe (rangListe)}
{@render blocTable(liste, rangListe, 0, liste.lignes.length)}
{/each}
{#if groupes === null}
{#each listes as liste, rangListe (rangListe)}
{@render sonde(liste, rangListe)}
{/each}
{:else}
{#each groupes as groupe, rangGroupe (rangGroupe)}
{@render blocTable(groupe, rangGroupe, 0, groupe.lignes.length)}
{/each}
{/if}
{/snippet}
</Feuille>
</div>
@ -136,9 +199,9 @@
</Feuille>
{:else}
{#each pages as page (page.rang)}
<Feuille rang={page.rang} papier={options.papier} orientation="portrait" feuille={feuilleDeListe(listes[page.feuille])} {bandeMm}>
<Feuille rang={page.rang} papier={options.papier} orientation="portrait" feuille={feuilleDeListe(groupes[page.feuille])} {bandeMm}>
{#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}
</Feuille>
{/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 {

View file

@ -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);
});
}
});
}
});

View file

@ -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]);
});
});

View file

@ -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<typeof feuillesTables>} feuilles
* @param {number[]} parGroupe un par feuille, entiers ≥ 1
* @returns {Array<ReturnType<typeof feuillesTables>[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<typeof grouperTours>} 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;
}

View file

@ -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]);
});
});

View file

@ -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

View file

@ -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', () => {

View file

@ -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 <style> qu'un module a rempli par
// l'objet CSSOM garde ainsi ce qu'il applique —, les feuilles adoptées en
// <style> à la fin de l'en-tête, aucun <script> ni préchargement de module
// ne reste, et une <base> en tête garde l'adresse du cadre pour les
// ressources relatives.
function serialiserDocument() {
const regles = (feuille) => [...feuille.cssRules].map((regle) => regle.cssText).join('\n');
const copie = document.documentElement.cloneNode(true);
const originaux = [...document.querySelectorAll('style')];
[...copie.querySelectorAll('style')].forEach((style, rang) => {
const feuille = originaux[rang]?.sheet;
if (feuille) style.textContent = regles(feuille);
});
for (const element of copie.querySelectorAll('script, link[rel="modulepreload"]')) element.remove();
const tete = copie.querySelector('head');
for (const feuille of document.adoptedStyleSheets) {
const style = document.createElement('style');
style.textContent = regles(feuille);
tete.append(style);
}
const base = document.createElement('base');
base.href = document.baseURI;
tete.prepend(base);
return `<!doctype html>\n${copie.outerHTML}`;
}
/**
* Imprime le document du cadre de l'épreuve en PDF, par le moteur
* d'impression de Chromium, dans une page neuve du même contexte, fermée
* ensuite : médias d'impression, fond imprimé, taille de page de la
* feuille de style. Les polices du document sont chargées avant
* l'impression. Le document de l'épreuve n'est pas modifié.
*
* @returns {Promise<{pages: number, mediaBox: Array<{largeur: number, hauteur: number}>, textes: string[]}>}
* lireImpression du PDF
*/
export async function imprimerEnPdf(context) {
const cadre = await context.frame();
const html = await cadre.evaluate(serialiserDocument);
const page = await context.page.context().newPage();
try {
await page.emulateMedia({ media: 'print' });
await page.setContent(html, { waitUntil: 'load' });
await page.evaluate(() => document.fonts.ready.then(() => undefined));
const octets = await page.pdf({ preferCSSPageSize: true, printBackground: true });
return await lireImpression(new Uint8Array(octets));
} finally {
await page.close();
}
}

View file

@ -20,13 +20,23 @@
// 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).
// 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
// Chromium (§ 11.7), y est déclarée aussi.
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import { optionsNavigateur } from './test/chromium.js';
import * as commandesDocumentation from './test/navigateur/commandes_documentation.js';
import * as commandesImpression from './test/navigateur/commandes_impression.js';
import { coeursDisponibles, reglagesSeries } from './test/machine.js';
// Les commandes du projet navigateur, chacune sous son nom : celles des
// captures d'écran, puis celle de l'impression.
const COMMANDES_NAVIGATEUR = Object.fromEntries(
[commandesDocumentation, commandesImpression].flatMap((module) => module.COMMANDES.map((nom) => [nom, module[nom]])),
);
// Les séries node suivent le nombre de cœurs de la machine (test/machine.js) :
// en deçà de quatre, des threads sur tous les cœurs, et les épreuves lourdes
// de la série surveillée passent dans la série longue (§ 14.14). Sous la
@ -181,7 +191,7 @@ export default defineConfig({
provide: { captures: { mode: commandesDocumentation.modeCaptures(process.env) } },
browser: { enabled: true, headless: true, provider: playwright(optionsNavigateur(process.env)),
instances: [{ browser: 'chromium' }],
commands: Object.fromEntries(commandesDocumentation.COMMANDES.map((nom) => [nom, commandesDocumentation[nom]])) } } },
commands: COMMANDES_NAVIGATEUR } } },
],
},
});