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)}
+ | {t('feuilles.tour', { tour })} |
+ {/each}
+ {#each liste.lignes as ligne (ligne.place)}
+ {#each ligne.cellules as cellule (cellule.tour)}
+ {@render celluleTour(cellule)}
+ {/each}
+ {/each}
+
+
+{/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 @@
{#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}
@@ -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