[ADD] print: preview screen, printable sheet, paper question

Printed pages must show exactly what will come out of the printer. An
Imprimer button in the event header opens a preview screen where each page
is a sheet at paper size, light whatever the theme, with its footer and,
under the strict draft rule, BROUILLON in outline; a proposal shown on a
retained plan prints as a draft. The paper is asked once per machine and
can be changed there. The table sheets offer one sheet per round. A failed
print shows IMPRESSION_ECHEC. The captures are republished with the button.
Checked: red first; 3651 node, 707 browser and 101 long tests green from
the index alone.

--- FR ---

[ADD] impression : aperçu, feuille imprimable, question du papier

Une page imprimée doit montrer exactement ce que l'imprimante rendra. Un
bouton Imprimer de la tête de l'événement ouvre un aperçu où chaque page
est une feuille au format du papier, claire quel que soit le thème, avec
son pied et, sous la règle stricte, BROUILLON au trait ; une proposition
montrée sur un plan retenu s'imprime en brouillon. Le papier se demande une
fois par machine et s'y change. Les feuilles par table offrent une feuille
par tour. Une impression échouée affiche IMPRESSION_ECHEC. Les captures
sont republiées avec le bouton.
Vérifié : rouge d'abord ; 3651 node, 707 navigateur et 101 longues vertes
depuis l'index seul.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-09 17:09:29 -04:00
parent 43dbfad8a5
commit 858018323b
48 changed files with 2383 additions and 45 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 145 KiB

File diff suppressed because one or more lines are too long

View file

@ -28,10 +28,13 @@
// événement après l'autre, et l'accueil montre chacun dès qu'il arrive.
//
// L'écran suit la séance : la liste sans événement ouvert, l'événement
// sinon ; le panneau « À propos » les remplace tant qu'il est ouvert. Chaque
// ouverture d'un événement remet l'état de navigation à son départ. Le
// bandeau, présent sur tout écran, ne défile pas : un écran plus haut que
// la fenêtre défile sous lui.
// sinon ; le panneau « À propos » les remplace tant qu'il est ouvert, et
// l'aperçu avant impression remplace l'événement tant que l'état de
// navigation en porte un (§ 11.7). Fermer l'aperçu rend la section d'où il
// s'est ouvert et le focus à sa commande « Imprimer… ». Chaque ouverture
// d'un événement remet l'état de navigation à son départ. Le bandeau,
// présent sur tout écran, ne défile pas : un écran plus haut que la
// fenêtre défile sous lui.
//
// Le thème part de la propriété themeInitial, ou de la préférence du
// système lue au montage ; le réglage de l'opérateur le change pour la
@ -52,6 +55,7 @@
import { creerPas } from './accueil/chargement.js';
import Bandeau from './Bandeau.svelte';
import Evenement from './evenement/Evenement.svelte';
import Apercu from './impression/Apercu.svelte';
import { blocAPropos, provenanceVisible, versionVisible } from './apropos.js';
import { creerEtatUi } from './etat_ui.js';
import { TABLES_INTERFACE } from './libelles.js';
@ -245,6 +249,17 @@
const version = versionVisible(provenance, produitVersion, t);
const provenanceAffichee = provenanceVisible(provenance, t);
// L'élément racine de l'application, où le focus se rend après l'aperçu.
let racineApp = $state(null);
// Ferme l'aperçu : l'événement revient sur la section d'origine, et le
// focus sur la commande « Imprimer… » de sa tête.
async function fermerApercu() {
uiApp.fermerApercu();
await tick();
racineApp?.querySelector('[data-ecran="evenement"] [data-commande="apercu"]')?.focus();
}
// « Liste des événements » : ferme l'événement, sans question ; sous un
// support rompu, la séance le refuse, et le refus s'affiche à côté de la
// commande.
@ -302,7 +317,7 @@
<title>{titre}</title>
</svelte:head>
<div class="app">
<div class="app" bind:this={racineApp}>
<Bandeau
etat={etatSeance}
{version}
@ -318,6 +333,8 @@
<APropos {bloc} {pressePapiers} fermer={() => uiApp.fermerApropos()} />
{:else if etatSeance.evenement === null}
<Accueil {provenance} version={produitVersion} {demonstrations} {lignes} {resumes} {refusLecture} />
{:else if $uiApp.apercu !== null}
<Apercu {provenance} version={produitVersion} fermer={fermerApercu} />
{:else}
<Evenement />
{/if}

View file

@ -5,8 +5,9 @@
// propos », le tour montré, la personne mise en évidence, la proposition
// demandée au plan, le texte d'un import en attente, les listes de noms, le
// plein écran, la place sélectionnée, la fiche ouverte, le choix en attente
// du plan, le refus posé sur une place et l'appartenance collante du
// formulaire d'ajout. Ce qu'il tient survit au démontage des sections.
// du plan, le refus posé sur une place, l'appartenance collante du
// formulaire d'ajout et l'aperçu avant impression. Ce qu'il tient survit au
// démontage des sections.
// Regarder n'écrit jamais (§ 8.4) : cet état ne reçoit ni la séance, ni la
// façade, ni un système de fichiers, et rien de ce qu'il tient ne quitte la
// mémoire.
@ -42,6 +43,12 @@ import { SECTION_OUVERTURE, SECTIONS } from './evenement/sections.js';
* Plan.svelte montre sur la place (§ 5.9)
* @property {string} appartenance l'appartenance collante du formulaire d'ajout
* des participants : celle du dernier ajout (§ 10.2), '' au départ
* @property {null|{vue: 'plan'|'accueil'|'tables', tour: number|'tous', listes: boolean,
* decoupe: 'auto'|'unePage'|'decoupe', parTour: boolean, retour: string}} apercu
* l'aperçu avant impression ouvert (§ 11.7), null fermé : la vue imprimée ; le
* tour du plan, 'tous' pour une vue de la soirée, jamais pour le plan ; les
* listes de noms du plan ; sa découpe en pages ; la feuille par table, une
* par tour ou la soirée en colonnes ; la section où l'aperçu se referme
*/
// L'état de navigation d'un événement qui s'ouvre, panneau « À propos »
@ -59,6 +66,22 @@ const DEPART = Object.freeze({
choix: null,
pose: null,
appartenance: '',
apercu: null,
});
/** Les vues que l'aperçu imprime (§ 11.7). */
export const VUES_IMPRESSION = Object.freeze(['plan', 'accueil', 'tables']);
/** Les découpes du plan imprimé, celles de decouperPlan (src/geometrie/pages.js). */
export const DECOUPES = Object.freeze(['auto', 'unePage', 'decoupe']);
// Le domaine de chaque réglage de l'aperçu que reglerApercu change.
const REGLAGES_APERCU = Object.freeze({
vue: (v) => VUES_IMPRESSION.includes(v),
tour: (v) => v === 'tous' || entierPositif(v),
listes: (v) => typeof v === 'boolean',
decoupe: (v) => DECOUPES.includes(v),
parTour: (v) => typeof v === 'boolean',
});
const GENRES_CHOIX = new Set(['personne', 'reservation']);
@ -71,6 +94,23 @@ const entierPositif = (n) => Number.isInteger(n) && n >= 1;
const estPlace = (place) =>
place !== null && typeof place === 'object' && entierPositif(place.table) && (place.siege === null || entierPositif(place.siege));
// L'aperçu que donnent les changements reçus sur courant, figé ; RangeError
// pour un réglage inconnu ou hors de son domaine, et pour le plan à « tous » :
// le plan s'imprime tour par tour.
function apercuValide(courant, changements) {
if (changements === null || typeof changements !== 'object') {
throw new RangeError(`aperçu : réglages attendus, reçu ${String(changements)}`);
}
for (const [nom, valeur] of Object.entries(changements)) {
if (!Object.hasOwn(REGLAGES_APERCU, nom) || !REGLAGES_APERCU[nom](valeur)) {
throw new RangeError(`aperçu : ${nom} hors du domaine, reçu ${JSON.stringify(valeur)}`);
}
}
const suivant = { ...courant, ...changements };
if (suivant.vue === 'plan' && suivant.tour === 'tous') throw new RangeError('aperçu : le plan s’imprime à un tour, non à « tous »');
return Object.freeze(suivant);
}
// Le choix du contrat, figé ; RangeError hors du contrat.
function choixValide(choix) {
const forme = choix !== null && typeof choix === 'object';
@ -103,7 +143,8 @@ function choixValide(choix) {
* prendreImport: () => null|{texte: string, origine: string},
* basculerListes: Function, basculerPleinEcran: Function,
* selectionner: Function, ouvrirFiche: Function, choisir: Function,
* poserSurPlace: Function, garderAppartenance: Function}}
* poserSurPlace: Function, garderAppartenance: Function,
* ouvrirApercu: Function, reglerApercu: Function, fermerApercu: Function}}
*/
export function creerEtatUi() {
let etat = Object.freeze({ ...DEPART, apropos: false });
@ -203,6 +244,30 @@ export function creerEtatUi() {
if (typeof texte !== 'string') throw new RangeError(`appartenance : chaîne attendue, reçu ${String(texte)}`);
changer({ appartenance: texte });
},
/** Ouvre l'aperçu avant impression sur la vue et le tour reçus : les
* listes du plan telles que l'écran les montre, la découpe proposée par
* défaut, la feuille par table couvrant la soirée ; la section courante
* est celle où il se refermera. RangeError hors du domaine
* (reglerApercu), sans rien changer. */
ouvrirApercu(demande) {
if (demande === null || typeof demande !== 'object' || !Object.hasOwn(demande, 'vue') || !Object.hasOwn(demande, 'tour')) {
throw new RangeError(`aperçu : { vue, tour } attendu, reçu ${JSON.stringify(demande)}`);
}
const depart = { vue: 'plan', tour: 1, listes: etat.listes, decoupe: 'auto', parTour: false, retour: etat.section };
changer({ apercu: apercuValide(depart, { vue: demande.vue, tour: demande.tour }) });
},
/** Change les réglages de l'aperçu ouvert — vue, tour, listes, decoupe,
* parTour — chacun dans son domaine ; RangeError hors de lui, pour un
* autre nom, ou sans aperçu ouvert, sans rien changer. */
reglerApercu(changements) {
if (etat.apercu === null) throw new RangeError('aperçu : aucun aperçu ouvert');
changer({ apercu: apercuValide(etat.apercu, changements) });
},
/** Ferme l'aperçu et rend la section d'où il s'est ouvert. */
fermerApercu() {
if (etat.apercu === null) return;
changer({ apercu: null, section: etat.apercu.retour });
},
/** Ouvre la fiche de la personne id, entier positif, dans la section des
* participants ; null referme la fiche, la section reste. RangeError
* sinon. */

View file

@ -24,6 +24,7 @@ const DEPART = {
choix: null,
pose: null,
appartenance: '',
apercu: null,
};
// Un état et le relevé de ce que reçoit son abonné.
@ -308,3 +309,97 @@ describe('état de navigation : le choix en attente (§ 5.8, § 7.1)', () => {
assert.equal(ui.etat().pose, null, 'une autre section');
});
});
describe('état de navigation : l’aperçu avant impression (§ 11.7)', () => {
test('ouvrirApercu ouvre la vue demandée au tour demandé, les listes du plan, la découpe proposée, la soirée en colonnes, et retient la section d’origine ; l’état est figé', () => {
const ui = creerEtatUi();
ui.allerA('propositions');
ui.basculerListes();
ui.ouvrirApercu({ vue: 'plan', tour: 2 });
assert.deepEqual(ui.etat(), {
...DEPART,
section: 'propositions',
listes: false,
apercu: { vue: 'plan', tour: 2, listes: false, decoupe: 'auto', parTour: false, retour: 'propositions' },
});
assert.ok(Object.isFrozen(ui.etat().apercu));
ui.ouvrirApercu({ vue: 'accueil', tour: 'tous' });
assert.deepEqual(ui.etat().apercu, { vue: 'accueil', tour: 'tous', listes: false, decoupe: 'auto', parTour: false, retour: 'propositions' });
});
test('ouvrirApercu refuse une vue inconnue, un tour qui n’est ni un entier positif ni « tous », et « tous » pour le plan, sans rien changer', () => {
const ui = creerEtatUi();
const avant = ui.etat();
const fautes = [
{ vue: 'badges', tour: 1 },
{ vue: 'Plan', tour: 1 },
{ vue: 'plan', tour: 0 },
{ vue: 'plan', tour: 1.5 },
{ vue: 'plan', tour: '2' },
{ vue: 'plan', tour: 'tous' },
{ vue: 'tables', tour: null },
{ tour: 1 },
null,
undefined,
];
for (const faute of fautes) assert.throws(() => ui.ouvrirApercu(faute), RangeError, JSON.stringify(faute));
assert.equal(ui.etat(), avant);
});
test('reglerApercu change la vue, le tour, les listes, la découpe et la feuille par tour, chacun dans son domaine ; hors de lui, ou sans aperçu ouvert, RangeError sans rien changer', () => {
const ui = creerEtatUi();
assert.throws(() => ui.reglerApercu({ vue: 'accueil' }), RangeError);
ui.ouvrirApercu({ vue: 'plan', tour: 1 });
ui.reglerApercu({ tour: 3 });
ui.reglerApercu({ listes: false, decoupe: 'unePage' });
ui.reglerApercu({ vue: 'tables', parTour: true });
assert.deepEqual(ui.etat().apercu, { vue: 'tables', tour: 3, listes: false, decoupe: 'unePage', parTour: true, retour: DEPART.section });
assert.ok(Object.isFrozen(ui.etat().apercu));
const avant = ui.etat();
for (const faute of [
{ vue: 'badges' },
{ tour: 0 },
{ listes: 'oui' },
{ decoupe: 'deux' },
{ parTour: 1 },
{ retour: 'plan' },
{ inconnu: true },
null,
]) {
assert.throws(() => ui.reglerApercu(faute), RangeError, JSON.stringify(faute));
}
ui.reglerApercu({ vue: 'accueil', tour: 'tous' });
assert.throws(() => ui.reglerApercu({ vue: 'plan' }), RangeError, 'le plan à « tous »');
ui.reglerApercu({ vue: 'plan', tour: 2 });
assert.equal(ui.etat().apercu.vue, 'plan');
assert.notEqual(ui.etat(), avant);
});
test('fermerApercu ferme l’aperçu et rend la section d’où il s’est ouvert ; fermé, il ne publie rien', () => {
const { ui, recus } = suivi();
ui.allerA('participants');
ui.ouvrirApercu({ vue: 'plan', tour: 1 });
ui.fermerApercu();
assert.deepEqual(ui.etat(), { ...DEPART, section: 'participants' });
const publies = recus.length;
ui.fermerApercu();
assert.equal(recus.length, publies);
});
test('fermerApercu rend la section d’origine même quand une autre s’est ouverte pendant l’aperçu — par le remède d’un refus', () => {
const ui = creerEtatUi();
ui.allerA('tables');
ui.ouvrirApercu({ vue: 'accueil', tour: 'tous' });
ui.allerA('propositions');
ui.fermerApercu();
assert.equal(ui.etat().section, 'tables');
assert.equal(ui.etat().apercu, null);
});
test('reinitialiser ferme l’aperçu : un autre événement s’ouvre sur sa section d’ouverture', () => {
const ui = creerEtatUi();
ui.ouvrirApercu({ vue: 'tables', tour: 'tous' });
ui.reinitialiser();
assert.equal(ui.etat().apercu, null);
});
});

View file

@ -12,12 +12,17 @@
// sous lui. Le nom se coupe où il le faut, même sans espace : un nom long
// de glyphes larges passe à la ligne au lieu d'élargir la page. Chaque état
// est un bloc : la commande et son refus en sont.
//
// « Imprimer… » ouvre l'aperçu avant impression (§ 11.7) sur le plan, au
// tour montré : toute impression passe par lui. Vivante dans tout mode,
// elle n'écrit rien.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
import { ETATS_PLAN, cleEtat } from './modele.js';
const t = getContext('t');
const seance = getContext('seance');
const ui = getContext('ui');
const courant = $derived($seance.evenement?.etat ?? null);
</script>
@ -31,6 +36,7 @@
</div>
{/each}
</div>
<Commande cle="impression.commande.ouvrir" nom="apercu" executer={() => ui.ouvrirApercu({ vue: 'plan', tour: $ui.tour })} />
</header>
<style>

View file

@ -0,0 +1,72 @@
/* © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) */
/* La feuille d'impression de l'aperçu (§ 11.7). Le document imprimé est
l'élément [data-impression] : une feuille [data-feuille] par page, aux
dimensions du papier en mm, qui demande par sa propriété page l'une des
quatre pages nommées ci-dessous — un papier, une orientation, à la taille
du papier, sans marge : la feuille porte elle-même sa marge. La
pagination est explicite, une feuille par page, et rien n'est laissé au
moteur : chaque feuille se termine par un saut de page.
À l'impression, seul le document se peint. Le masquage est gardé par
body:has([data-impression]) : sans aperçu ouvert, rien n'est masqué, et
l'impression d'un autre écran par le navigateur le rend tel qu'il est,
jamais en page blanche. Sous l'aperçu, tout élément qui n'est ni le
document, ni dans lui, ni l'un de ses ancêtres, disparaît : le bandeau,
la barre de l'aperçu, la feuille gabarit. Les ancêtres du document
perdent marges, retraits, bords et hauteurs imposées : la première
feuille commence au coin de la première page, et la hauteur d'un
ancêtre ne tronque jamais le document à une page. */
@page gtt-a4-portrait {
size: A4 portrait;
margin: 0;
}
@page gtt-a4-paysage {
size: A4 landscape;
margin: 0;
}
@page gtt-lettre-portrait {
size: letter portrait;
margin: 0;
}
@page gtt-lettre-paysage {
size: letter landscape;
margin: 0;
}
@media print {
body:has([data-impression]) *:not([data-impression], [data-impression] *, :has([data-impression])) {
display: none !important;
}
body:has([data-impression]),
body:has([data-impression]) :has([data-impression]) {
display: block !important;
position: static !important;
height: auto !important;
min-height: 0 !important;
max-height: none !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
overflow: visible !important;
}
[data-impression] {
display: block !important;
margin: 0 !important;
padding: 0 !important;
}
[data-impression] [data-feuille] {
margin: 0 !important;
border: 0 !important;
box-shadow: none !important;
break-after: page;
}
}

View file

@ -0,0 +1,527 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'aperçu avant impression dans un vrai moteur de rendu (§ 11.7, § 8.4,
// § 8.5, § 14.5, § 18.6) : l'écran data-ecran="apercu", que la commande
// « Imprimer… » de la tête de l'événement ouvre, et son document imprimé
// [data-impression], une feuille [data-feuille] par page. Chaque mesure se
// lit sur le style calculé et la boîte rendue, jamais sur une classe : les
// dimensions d'une feuille, son pied de cinq éléments, le filigrane d'un
// brouillon, le masquage sous les médias d'impression émulés, le papier
// clair en thème sombre, la question du papier, l'impression en lecture et
// sur un plan bloqué — aucune écriture de l'événement —, le retour à la
// section d'origine, et la bande mesurée sur la feuille gabarit.
//
// 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, et la règle du brouillon de
// src/application/sorties.js.
//
// Le projet navigateur emploie expect, et test et describe de Vitest
// (§ 14.8).
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { cdp } from 'vitest/browser';
import { appliquerCommande } from '../../application/commandes.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { estBrouillon, horodatagePlacement } from '../../application/sorties.js';
import { creerTraduction } from '../../application/traduction.js';
import { texteLivre } from '../../demo/livrees.js';
import { constante } from '../../geometrie/constantes.js';
import { PAPIERS } from '../../geometrie/papiers.js';
import { analyser } from '../../stockage/document.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { FICHIER_REGLAGES } from '../../stockage/reglages_locaux.js';
import { VERSION } from '../../version.genere.js';
import { fixerCadre } from '../../../test/navigateur/entrees.js';
import {
attendre,
chargeBloquee,
chargePrincipale,
cliquer,
creerContexte,
fichierReglages,
ouvrirApercu,
ouvrirEvenement,
} from '../../../test/navigateur/ecrans.js';
import { attenuations, composantes, contraste } from '../../../test/navigateur/rendu.js';
import { creerImprimanteSimulee } from '../../../test/imprimante_simulee.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { MARQUES } from '../marques.js';
import { MM_PAR_PX } from './modele.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
// Images attendues au plus par étape : bien au-delà d'une écriture simulée
// et de son rendu, et sous le délai d'une épreuve, pour qu'une étape qui ne
// vient pas se nomme.
const IMAGES = 300;
// Une feuille se mesure au dixième de millimètre (§ 11.7).
const TOLERANCE_MM = 0.1;
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 tous = (m, selecteur) => [...m.racine.querySelectorAll(selecteur)];
const feuilles = (m) => tous(m, '[data-impression] [data-feuille]');
const mm = (px) => px * MM_PAR_PX;
const plat = (chaine) => chaine.replace(/\s+/g, ' ').trim();
const texte = (element) => plat(element.textContent);
// 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 principale retenue puis bloquée, son retenu daté : un placement
// définitif, que le pied date.
const HORODATAGE = '2026-10-09T19:00:00-04:00';
function chargeBloqueeDatee() {
const charge = chargeBloquee();
charge.retenu.horodatage = HORODATAGE;
return charge;
}
// La principale, sa proposition retenue, puis un tour de plus à la
// soirée : le retenu porte les tours de 1 à R − 1, pas le dernier, sur un
// plan retenu.
function chargeRetenuCourt() {
const principale = chargePrincipale();
const retenue = appliquerCommande('retenirProposition', principale, { id: principale.propositions[0].id }).charge;
return appliquerCommande('changerTours', retenue, { tours: retenue.evenement.tours + 1 }).charge;
}
// Les écritures relevées depuis le rang donné qui touchent l'état ou le
// journal d'un événement.
const ecrituresEvenement = (m, depuis) =>
m.trace.slice(depuis).filter(([, , chemin]) => [SUFFIXES.etat, SUFFIXES.journal, SUFFIXES.precedent].some((suffixe) => chemin.endsWith(suffixe)));
describe('l’écran d’aperçu (§ 11.7)', () => {
test('« Imprimer… » de la tête ouvre l’écran d’aperçu, qui remplace l’événement ; son document est [data-impression], une feuille par page', async () => {
const m = await ouvrirApercu(contexte, chargePrincipale());
expect(dans(m, '[data-ecran="evenement"]')).toBeNull();
expect(tous(m, '[data-ecran="apercu"] [data-impression]')).toHaveLength(1);
expect(feuilles(m).length).toBeGreaterThan(0);
expect(feuilles(m).map((f) => f.dataset.feuille)).toEqual(feuilles(m).map((_, rang) => String(rang)));
// Aucun identifiant en double dans la page.
const ids = [...document.querySelectorAll('[id]')].map((e) => e.id);
expect(ids.filter((id, rang) => ids.indexOf(id) !== rang)).toEqual([]);
});
test('une feuille A4 portrait mesure 210 × 297 mm, une feuille Lettre 215,9 × 279,4 mm, au dixième de mm', async () => {
for (const papier of ['A4', 'Lettre']) {
const m = await ouvrirApercu(contexte, chargePrincipale(), { papier });
await choisirVue(m, 'accueil');
const { largeur, hauteur } = PAPIERS[papier];
for (const feuille of feuilles(m)) {
const boite = feuille.getBoundingClientRect();
expect(Math.abs(mm(boite.width) - largeur), `${papier} : largeur ${mm(boite.width)}`).toBeLessThanOrEqual(TOLERANCE_MM);
expect(Math.abs(mm(boite.height) - hauteur), `${papier} : hauteur ${mm(boite.height)}`).toBeLessThanOrEqual(TOLERANCE_MM);
}
await contexte.defaire();
}
});
test('le plan s’imprime dans l’orientation de sa découpe : chaque feuille du plan a les dimensions du papier, couché ou non', async () => {
const m = await ouvrirApercu(contexte, chargePrincipale());
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 demandée', () => m.ui.etat().apercu.decoupe === 'decoupe', IMAGES);
const { largeur, hauteur } = PAPIERS.A4;
for (const feuille of feuilles(m)) {
const boite = feuille.getBoundingClientRect();
const dims = [mm(boite.width), mm(boite.height)].sort((a, b) => a - b);
expect(Math.abs(dims[0] - largeur)).toBeLessThanOrEqual(TOLERANCE_MM);
expect(Math.abs(dims[1] - hauteur)).toBeLessThanOrEqual(TOLERANCE_MM);
}
});
});
describe('le pied de chaque feuille (§ 11.7, § 18.6)', () => {
test('chaque feuille porte son pied, et chaque pied les cinq éléments : nom, date, tour, horodatage du placement, version', async () => {
const charge = chargeBloqueeDatee();
const m = await ouvrirApercu(contexte, charge);
expect(charge.evenement.date).toBeNull();
expect(feuilles(m).length).toBeGreaterThan(0);
expect(tous(m, '[data-pied]').filter((pied) => pied.closest('[data-impression]') !== null)).toHaveLength(feuilles(m).length);
for (const feuille of feuilles(m)) {
const pieds = feuille.querySelectorAll('[data-pied]');
expect(pieds).toHaveLength(1);
const lu = texte(pieds[0]);
expect(lu).toContain(charge.evenement.nom);
expect(lu).toContain(t('impression.pied.sansDate'));
expect(lu).toContain(t('impression.pied.tour', { tour: 1 }));
expect(lu).toContain(t('impression.pied.placement'));
expect(lu).toContain(t('format.horodatage', { annee: 2026, mois: 10, jour: 9, heure: 19, minute: 0 }));
expect(lu).toContain(VERSION.affichee);
}
expect(horodatagePlacement(charge, { source: 'retenu' })).toBe(HORODATAGE);
});
test('un plan de plusieurs pages, la grande démonstration : chaque feuille porte son propre pied, le même sur chacune', async () => {
const charge = analyser(texteLivre('grande')).charge;
const m = await ouvrirApercu(contexte, charge, { section: 'participants' });
expect(feuilles(m).length).toBeGreaterThan(1);
const pieds = feuilles(m).map((feuille) => feuille.querySelectorAll('[data-pied]'));
expect(pieds.map((liste) => liste.length)).toEqual(feuilles(m).map(() => 1));
const lus = pieds.map((liste) => texte(liste[0]));
expect(new Set(lus).size).toBe(1);
expect(lus[0]).toContain(charge.evenement.nom);
expect(lus[0]).toContain(t('impression.pied.tour', { tour: 1 }));
});
test('les listes portent « Tous les tours » ; un placement sans horodatage le dit', async () => {
const m = await ouvrirApercu(contexte, chargePrincipale());
await choisirVue(m, 'accueil');
for (const feuille of feuilles(m)) {
const lu = texte(feuille.querySelector('[data-pied]'));
expect(lu).toContain(t('impression.pied.tousLesTours'));
expect(lu).toContain(t('impression.pied.placementInconnu'));
}
});
test('dans une construction de documentation, la version devient la marque de provenance', async () => {
const m = await ouvrirApercu(contexte, chargeBloqueeDatee(), { options: { provenance: 'documentation' } });
for (const feuille of feuilles(m)) {
const lu = texte(feuille.querySelector('[data-pied]'));
expect(lu).toContain(t('sorties.version.documentation'));
expect(lu).not.toContain(VERSION.affichee);
}
});
});
describe('le filigrane BROUILLON (§ 11.7, § 14.5)', () => {
// Le filigrane d'une feuille, son texte au trait, et le fond du papier.
function lireFiligrane(feuille) {
const filigrane = feuille.querySelector('[data-filigrane]');
const trait = filigrane?.querySelector('[data-marque="filigrane"]') ?? null;
return { filigrane, trait, papier: composantes(getComputedStyle(feuille).backgroundColor) };
}
test('peint sur chaque feuille d’un brouillon — une proposition montrée —, au trait, sans remplissage, sans opacité ni filtre, à 3:1 sur le papier, dans les deux thèmes', async () => {
const charge = chargePrincipale();
expect(estBrouillon(charge, { source: 'proposition', tour: 1 })).toBe(true);
expect(MARQUES.some(({ nom }) => nom === 'filigrane')).toBe(true);
const m = await ouvrirApercu(contexte, charge);
for (const theme of ['clair', 'sombre']) {
await cliquer(m, `[data-bandeau] [data-commande="theme"][data-theme-choix="${theme}"]`);
await attendre(`le thème ${theme}`, () => document.documentElement.getAttribute('data-theme') === theme, IMAGES);
for (const feuille of feuilles(m)) {
const { trait, papier } = lireFiligrane(feuille);
expect(trait, 'le filigrane').not.toBeNull();
expect(texte(trait)).toBe(t('impression.filigrane'));
const style = getComputedStyle(trait);
expect(style.fill).toBe('none');
expect(Number.parseFloat(style.strokeWidth)).toBeGreaterThan(0);
expect(style.pointerEvents === 'none' || getComputedStyle(trait.ownerSVGElement).pointerEvents === 'none').toBe(true);
expect(attenuations(trait), 'aucune atténuation').toEqual([]);
const couleur = composantes(style.stroke);
expect(contraste(couleur, papier), `${theme} : ${style.stroke} sur ${getComputedStyle(feuille).backgroundColor}`).toBeGreaterThanOrEqual(3);
// Tourné sur l'élément lui-même, par-dessus le contenu.
expect(style.transform).not.toBe('none');
const contenu = feuille.querySelector('[data-contenu]');
expect(contenu.compareDocumentPosition(trait) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
}
}
});
test('absent d’un retenu imprimé sur un plan bloqué, au tour qu’il porte', async () => {
const charge = chargeBloqueeDatee();
expect(estBrouillon(charge, { source: 'retenu', tour: 1 })).toBe(false);
expect(estBrouillon(charge, { source: 'retenu', tour: 'tous' })).toBe(false);
const m = await ouvrirApercu(contexte, charge);
for (const vue of ['plan', 'accueil', 'tables']) {
if (vue !== 'plan') await choisirVue(m, vue);
expect(feuilles(m).length, vue).toBeGreaterThan(0);
expect(tous(m, '[data-impression] [data-filigrane]'), vue).toHaveLength(0);
}
});
test('peint sur chaque feuille d’une proposition montrée sur un plan bloqué : l’aperçu imprime la source du plan à l’écran, pas le retenu (Q5 b)', async () => {
const charge = chargeBloqueeDatee();
expect(estBrouillon(charge, { source: 'retenu', tour: 1 })).toBe(false);
expect(estBrouillon(charge, { source: 'proposition', tour: 1 })).toBe(true);
const m = await ouvrirEvenement(contexte, charge, { section: 'propositions', options: { fichiers: [fichierReglages('A4')] } });
await cliquer(m, '[data-classement] [data-commande="voir"]');
await attendre('la proposition montrée', () => m.ui.etat().proposition !== null, IMAGES);
await cliquer(m, '[data-ecran="evenement"] [data-commande="apercu"]');
await attendre('le document', () => feuilles(m).length > 0, IMAGES);
for (const vue of ['plan', 'accueil', 'tables']) {
if (vue !== 'plan') await choisirVue(m, vue);
expect(feuilles(m).length, vue).toBeGreaterThan(0);
for (const feuille of feuilles(m)) expect(feuille.querySelectorAll('[data-filigrane]'), vue).toHaveLength(1);
}
});
test('un retenu plus court que la soirée : le plan d’un tour qu’il porte sans filigrane ; celui du tour qu’il ne porte pas, et les listes de la soirée, avec', async () => {
const charge = chargeRetenuCourt();
const R = charge.evenement.tours;
expect(charge.retenu.tours).toBe(R - 1);
expect(estBrouillon(charge, { source: 'retenu', tour: 1 })).toBe(false);
expect(estBrouillon(charge, { source: 'retenu', tour: R })).toBe(true);
expect(estBrouillon(charge, { source: 'retenu', tour: 'tous' })).toBe(true);
const m = await ouvrirApercu(contexte, charge);
expect(m.ui.etat().apercu.tour).toBe(1);
expect(feuilles(m).length).toBeGreaterThan(0);
expect(tous(m, '[data-impression] [data-filigrane]'), 'tour 1').toHaveLength(0);
const choix = dans(m, '[data-ecran="apercu"] [data-champ="tour"]');
choix.value = String(R);
choix.dispatchEvent(new Event('change', { bubbles: true }));
await attendre(`le tour ${R}`, () => m.ui.etat().apercu.tour === R, IMAGES);
await attendre(`le filigrane du tour ${R}`, () => feuilles(m).length > 0 && feuilles(m).every((f) => f.querySelector('[data-filigrane]')), IMAGES);
expect(texte(feuilles(m)[0].querySelector('[data-pied]'))).toContain(t('impression.pied.tour', { tour: R }));
await choisirVue(m, 'accueil');
for (const feuille of feuilles(m)) expect(feuille.querySelectorAll('[data-filigrane]'), 'accueil').toHaveLength(1);
});
});
describe('les médias d’impression (§ 11.7)', () => {
// Émule les médias d'impression le temps de lire.
async function sousImpression(lire) {
await cdp().send('Emulation.setEmulatedMedia', { media: 'print' });
try {
return lire();
} finally {
await cdp().send('Emulation.setEmulatedMedia', { media: '' });
}
}
const affiche = (element) => getComputedStyle(element).display !== 'none';
test('sous l’aperçu, le bandeau, la barre et la feuille gabarit disparaissent ; chaque feuille reste', async () => {
const m = await ouvrirApercu(contexte, chargePrincipale());
const bandeau = dans(m, '[data-bandeau]');
const barre = dans(m, '[data-ecran="apercu"] [data-commande="imprimer"]');
expect(affiche(bandeau) && affiche(barre)).toBe(true);
await sousImpression(() => {
expect(affiche(bandeau), 'bandeau').toBe(false);
expect(barre.checkVisibility(), 'barre').toBe(false);
for (const feuille of feuilles(m)) expect(feuille.checkVisibility(), 'feuille').toBe(true);
const horsDocument = tous(m, '[data-feuille], [data-pied], [data-contenu]').filter((e) => e.closest('[data-impression]') === null);
for (const element of horsDocument) expect(element.checkVisibility(), 'gabarit').toBe(false);
});
});
test('hors de l’aperçu, rien n’est masqué : l’impression d’un autre écran le rend tel qu’il est', async () => {
const m = await ouvrirEvenement(contexte, chargePrincipale(), { section: 'participants' });
await sousImpression(() => {
expect(affiche(dans(m, '[data-bandeau]'))).toBe(true);
expect(dans(m, '[data-ecran="evenement"]').checkVisibility()).toBe(true);
expect(dans(m, '[data-section="participants"]').checkVisibility()).toBe(true);
});
});
});
describe('le papier est clair dans les deux thèmes (§ 11.7, § 14.5)', () => {
test('thème sombre : sous [data-impression], le fond du papier, le texte et le remplissage d’un plateau sont les jetons du clair', async () => {
const m = await ouvrirApercu(contexte, chargePrincipale());
const lireSous = () => {
const contenu = feuilles(m)[0].querySelector('[data-contenu]');
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
const plateau = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
plateau.setAttribute('r', '5');
plateau.style.fill = 'var(--couleur-table-fond)';
svg.append(plateau);
contenu.append(svg);
try {
return {
plateau: getComputedStyle(plateau).fill,
papier: getComputedStyle(feuilles(m)[0]).backgroundColor,
texte: getComputedStyle(feuilles(m)[0].querySelector('[data-pied]')).color,
};
} finally {
svg.remove();
}
};
const clair = lireSous();
await cliquer(m, '[data-bandeau] [data-commande="theme"][data-theme-choix="sombre"]');
await attendre('le thème sombre', () => document.documentElement.getAttribute('data-theme') === 'sombre', IMAGES);
const sombre = lireSous();
expect(sombre).toEqual(clair);
expect(clair.plateau).toBe('rgb(255, 255, 255)');
// La page, elle, a pris le sombre.
expect(getComputedStyle(document.body).backgroundColor).not.toBe(clair.papier);
});
});
describe('la question du papier (§ 8.5, § 11.2)', () => {
test('sans papier, l’aperçu pose la question ; choisir l’écrit, l’aperçu le prend ; un second aperçu, puis un App remonté sur le même système, ne redemandent pas ; « Changer » le modifie', async () => {
const charge = chargeBloqueeDatee();
const m = await ouvrirApercu(contexte, charge, { papier: null });
expect(dans(m, '[data-ecran="apercu"] [data-papier="question"]')).not.toBeNull();
expect(dans(m, '[data-impression]')).toBeNull();
expect(dans(m, '[data-ecran="apercu"] [data-commande="imprimer"]')).toBeNull();
const avant = m.trace.length;
await cliquer(m, '[data-papier="question"] [data-cible="A4"] [data-commande="choisirPapier"]');
await attendre('le document sur A4', () => feuilles(m).length > 0, IMAGES);
expect(m.seance.etat().papier).toBe('A4');
expect(m.trace.slice(avant).map(([, , chemin]) => chemin)).toContain(FICHIER_REGLAGES);
expect(ecrituresEvenement(m, avant)).toEqual([]);
expect(texte(dans(m, '[data-ecran="apercu"] [data-papier="choisi"]'))).toContain(t('impression.papier.nom.a4'));
// Un second aperçu ne redemande pas.
await cliquer(m, '[data-ecran="apercu"] [data-commande="fermerApercu"]');
await cliquer(m, '[data-ecran="evenement"] [data-commande="apercu"]');
await attendre('le document, sans question', () => feuilles(m).length > 0, IMAGES);
expect(dans(m, '[data-papier="question"]')).toBeNull();
// Un App remonté sur le même système ne redemande pas.
m.demonter();
const second = await contexte.monter({ systeme: { disque: m.disque } });
await cliquer(second, '[data-evenement="soiree"] [data-commande="ouvrir"]');
await attendre("l'événement", () => second.racine.querySelector('[data-ecran="evenement"]'), IMAGES);
await cliquer(second, '[data-ecran="evenement"] [data-commande="apercu"]');
await attendre('le document, sans question', () => second.racine.querySelector('[data-impression] [data-feuille]'), IMAGES);
expect(second.racine.querySelector('[data-papier="question"]')).toBeNull();
// « Changer » le modifie.
await cliquer(second, '[data-papier="choisi"] [data-commande="changerPapier"]');
await cliquer(second, '[data-papier="question"] [data-cible="Lettre"] [data-commande="choisirPapier"]');
await attendre('le papier Lettre', () => second.seance.etat().papier === 'Lettre', IMAGES);
await attendre('la question retirée', () => second.racine.querySelector('[data-papier="question"]') === null, IMAGES);
const boite = second.racine.querySelector('[data-impression] [data-feuille]').getBoundingClientRect();
const dims = [mm(boite.width), mm(boite.height)].sort((a, b) => a - b);
expect(Math.abs(dims[0] - PAPIERS.Lettre.largeur)).toBeLessThanOrEqual(TOLERANCE_MM);
expect(Math.abs(dims[1] - PAPIERS.Lettre.hauteur)).toBeLessThanOrEqual(TOLERANCE_MM);
});
});
describe('imprimer en lecture et sur un plan bloqué (§ 8.4, § 2.1 garantie 3)', () => {
for (const [cas, charge] of [
['en lecture', chargePrincipale],
['sur un plan bloqué', chargeBloqueeDatee],
]) {
test(`${cas} : l’aperçu s’ouvre, « Imprimer » appelle imprimer une fois avec le papier et l’orientation ; rien de l’événement ne s’écrit`, async () => {
const imprimante = creerImprimanteSimulee();
const m = await ouvrirApercu(contexte, charge(), { papier: 'Lettre', options: { imprimante } });
expect(m.seance.etat().mode).toBe('lecture');
const avant = m.trace.length;
await choisirVue(m, 'accueil');
await cliquer(m, '[data-ecran="apercu"] [data-commande="imprimer"]');
await attendre("l'impression demandée", () => imprimante.appels().length === 1, IMAGES);
expect(imprimante.appels()).toEqual([{ papier: 'Lettre', orientation: 'portrait' }]);
await choisirVue(m, 'plan');
await cliquer(m, '[data-ecran="apercu"] [data-commande="imprimer"]');
await attendre("la seconde impression demandée", () => imprimante.appels().length === 2, IMAGES);
const boite = feuilles(m)[0].getBoundingClientRect();
expect(imprimante.appels()[1]).toEqual({ papier: 'Lettre', orientation: boite.width > boite.height ? 'paysage' : 'portrait' });
expect(ecrituresEvenement(m, avant)).toEqual([]);
expect(m.trace.slice(avant)).toEqual([]);
expect(dans(m, '[data-refus]')).toBeNull();
});
}
test('une impression annulée ne dit rien ; un échec pose IMPRESSION_ECHEC sur la commande « Imprimer », sa cause nommée', async () => {
const annulee = creerImprimanteSimulee({ issue: 'annule' });
const m = await ouvrirApercu(contexte, chargePrincipale(), { options: { imprimante: annulee } });
await cliquer(m, '[data-ecran="apercu"] [data-commande="imprimer"]');
await attendre("l'impression demandée", () => annulee.appels().length === 1, IMAGES);
await attendre('la commande rendue', () => dans(m, '[data-commande="imprimer"]').getAttribute('aria-busy') === null, IMAGES);
expect(dans(m, '[data-refus]')).toBeNull();
await contexte.defaire();
const cause = 'Imprimante hors ligne';
const enEchec = creerImprimanteSimulee({ issue: 'echec', cause });
const n = await ouvrirApercu(contexte, chargePrincipale(), { options: { imprimante: enEchec } });
await cliquer(n, '[data-ecran="apercu"] [data-commande="imprimer"]');
const refus = await attendre('le refus sur la commande', () => dans(n, '[data-commande="imprimer"]')?.parentElement.querySelector('[data-refus="IMPRESSION_ECHEC"]'), IMAGES);
expect(texte(refus.querySelector('p'))).toBe(plat(t('IMPRESSION_ECHEC', { cause })));
expect(enEchec.appels()).toHaveLength(1);
});
});
describe('fermer l’aperçu (§ 11.7)', () => {
test('« Fermer l’aperçu » revient à la section d’origine, le focus sur « Imprimer… »', async () => {
const m = await ouvrirApercu(contexte, chargePrincipale(), { section: 'participants' });
await cliquer(m, '[data-ecran="apercu"] [data-commande="fermerApercu"]');
await attendre("l'événement", () => dans(m, '[data-section="participants"]'), IMAGES);
expect(dans(m, '[data-ecran="apercu"]')).toBeNull();
await attendre('le focus sur « Imprimer… »', () => document.activeElement === dans(m, '[data-ecran="evenement"] [data-commande="apercu"]'), IMAGES);
});
});
describe('la bande mesurée (HAUTEUR_BANDE_IMPRESSION, § 14.1)', () => {
// La hauteur de la zone de contenu de la première feuille, en mm.
const contenuMm = (m) => mm(feuilles(m)[0].querySelector('[data-contenu]').getBoundingClientRect().height);
test('un pied grandi de 20 mm par l’épreuve grandit la bande d’autant : la zone de contenu de chaque feuille perd 20 mm, au dixième', async () => {
const m = await ouvrirApercu(contexte, chargeBloqueeDatee());
await choisirVue(m, 'accueil');
const avant = contenuMm(m);
const marge = constante('MARGE_PAGE_MM');
expect(avant).toBeLessThan(PAPIERS.A4.hauteur - 2 * marge);
const feuille = document.createElement('style');
feuille.textContent = '[data-pied] { padding-bottom: 20mm !important; }';
document.head.append(feuille);
try {
await cliquer(m, '[data-ecran="apercu"] [data-commande="fermerApercu"]');
await cliquer(m, '[data-ecran="evenement"] [data-commande="apercu"]');
await attendre('le document', () => feuilles(m).length > 0, IMAGES);
await choisirVue(m, 'accueil');
const apres = contenuMm(m);
expect(Math.abs(avant - apres - 20), `${avant} − ${apres}`).toBeLessThanOrEqual(TOLERANCE_MM);
// La feuille garde sa taille : la bande prend sur le contenu.
expect(Math.abs(mm(feuilles(m)[0].getBoundingClientRect().height) - PAPIERS.A4.hauteur)).toBeLessThanOrEqual(TOLERANCE_MM);
} finally {
feuille.remove();
}
});
// Chaque feuille du plan, en mm : sa tuile — le <svg> de la page, que la
// vue rend à la taille que la découpe donne à la surface utile —, sa
// feuille et sa zone de contenu.
const tuiles = (m) =>
tous(m, '[data-impression][data-vue-impression="plan"] [data-feuille]').map((feuille) => {
const tuile = feuille.querySelector('[data-contenu] > svg');
expect(tuile, `la tuile de la feuille ${feuille.dataset.feuille}`).not.toBeNull();
const boite = tuile.getBoundingClientRect();
const page = feuille.getBoundingClientRect();
return {
largeur: mm(boite.width),
hauteur: mm(boite.height),
pageLargeur: mm(page.width),
paysage: page.width > page.height,
contenu: mm(feuille.querySelector('[data-contenu]').getBoundingClientRect().height),
};
});
test('la découpe du plan reçoit la même bande : la tuile de chaque page a la hauteur de la zone de contenu, et perd 20 mm quand le pied en gagne 20, au dixième', async () => {
const marge = constante('MARGE_PAGE_MM');
const m = await ouvrirApercu(contexte, chargeBloqueeDatee());
const avant = tuiles(m);
expect(avant.length).toBeGreaterThan(0);
for (const tuile of avant) {
expect(Math.abs(tuile.hauteur - tuile.contenu), JSON.stringify(tuile)).toBeLessThanOrEqual(TOLERANCE_MM);
expect(Math.abs(tuile.largeur - (tuile.pageLargeur - 2 * marge)), JSON.stringify(tuile)).toBeLessThanOrEqual(TOLERANCE_MM);
}
const feuille = document.createElement('style');
feuille.textContent = '[data-pied] { padding-bottom: 20mm !important; }';
document.head.append(feuille);
try {
await cliquer(m, '[data-ecran="apercu"] [data-commande="fermerApercu"]');
await cliquer(m, '[data-ecran="evenement"] [data-commande="apercu"]');
await attendre('le plan', () => dans(m, '[data-impression][data-vue-impression="plan"] [data-feuille]'), IMAGES);
const apres = tuiles(m);
// La même orientation : la différence ne tient qu'à la bande.
expect(apres.map(({ paysage }) => paysage)).toEqual(avant.map(({ paysage }) => paysage));
for (const [rang, tuile] of apres.entries()) {
expect(Math.abs(tuile.hauteur - tuile.contenu), JSON.stringify(tuile)).toBeLessThanOrEqual(TOLERANCE_MM);
expect(Math.abs(avant[rang].hauteur - tuile.hauteur - 20), `${avant[rang].hauteur} − ${tuile.hauteur}`).toBeLessThanOrEqual(TOLERANCE_MM);
}
} finally {
feuille.remove();
}
});
});

View file

@ -0,0 +1,247 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// L'écran d'aperçu avant impression (§ 11.7) : la feuille de style
// d'impression réelle appliquée au document vivant, la même mise en page
// regardée avant d'être envoyée — jamais une seconde. App le monte à la
// place de l'événement tant que l'état de navigation porte un aperçu ; il
// n'a pour ancêtres que la racine, et son document n'est jamais tronqué à
// une page par un ancêtre de hauteur fixe. Toute impression passe par lui.
//
// Sa barre — titre, libellé de mode, vue imprimée, réglages de la vue,
// papier, « Imprimer », « Fermer l'aperçu » — ne s'imprime pas. Son
// document, [data-impression], est la vue choisie : une feuille par page,
// chacune sous le pied et, pour un brouillon (estBrouillon), le filigrane.
// La sortie est celle du plan à l'écran : le retenu, la proposition
// montrée, ou les réservations seules ; le pied porte son horodatage
// (piedDePage). Sans papier choisi, l'aperçu pose la question du papier
// (Papier.svelte) à la place du document.
//
// La bande — ce qu'une feuille porte hors de sa zone de contenu — se
// mesure sur une feuille gabarit, que la vue rend hors de la vue, à
// l'ouverture et chaque fois que la vue, le papier ou le pied changent
// (HAUTEUR_BANDE_IMPRESSION) ; le document se rend ensuite, la bande
// reçue par la vue, qui la passe à sa mise en page.
//
// « Imprimer » demande à l'imprimante du contexte le papier et
// l'orientation de la vue ; un échec pose son refus (refusImpression) sur
// la commande, une annulation ne dit rien. Rien de l'événement ne
// s'écrit : l'aperçu ne reçoit de la séance que son état, et le papier
// passe par choisirPapier.
import '../impression.css';
import { getContext } from 'svelte';
import { estBrouillon, piedDePage, refusImpression } from '../../application/sorties.js';
import { constante } from '../../geometrie/constantes.js';
import { DECOUPES, VUES_IMPRESSION } from '../etat_ui.js';
import { cadreDeMode } from '../evenement/modele.js';
import Commande from '../primitives/Commande.svelte';
import Papier from '../sorties/Papier.svelte';
import FeuillesTables from './FeuillesTables.svelte';
import ListeAccueil from './ListeAccueil.svelte';
import PlanImprime from './PlanImprime.svelte';
import { bandeDeFeuille, cleDecoupe, cleVue, feuilleDe, optionsApercu, sortieApercu, toursOfferts } from './modele.js';
// provenance et version : celles du bandeau, que le pied reprend ;
// fermer : ferme l'aperçu et rend le focus à « Imprimer… ».
let { provenance, version, fermer } = $props();
const t = getContext('t');
const seance = getContext('seance');
const ui = getContext('ui');
const imprimante = getContext('imprimante');
// Le composant de chaque vue imprimée.
const VUES = new Map([
['plan', PlanImprime],
['accueil', ListeAccueil],
['tables', FeuillesTables],
]);
const marge = constante('MARGE_PAGE_MM');
const charge = $derived($seance.charge);
const options = $derived(optionsApercu($ui.apercu, $seance.papier));
const sortie = $derived(sortieApercu(charge, options, $ui.proposition));
const pied = $derived(
piedDePage(charge, { source: sortie.source, proposition: sortie.proposition, tour: sortie.tour, provenance, version, texte: t }),
);
const feuille = $derived(feuilleDe(pied, estBrouillon(charge, { source: sortie.source, tour: sortie.tour })));
const Vue = $derived(VUES.get(options.vue));
const cadre = $derived(cadreDeMode($seance));
const tours = $derived(toursOfferts(charge.evenement.tours));
// La bande mesurée sur le gabarit, null avant la première mesure ; ce qui
// la change remonte le gabarit, qui se mesure de nouveau.
let bandeMm = $state(null);
const cleGabarit = $derived(JSON.stringify([options.vue, options.papier, feuille.elements]));
// L'orientation que la vue a choisie : celle des listes, ou celle de la
// découpe du plan.
let orientationVue = $state(null);
const orientation = $derived(options.orientation ?? orientationVue);
function mesurer(conteneur) {
const gabarit = conteneur.firstElementChild;
const contenu = gabarit.querySelector('[data-contenu]');
bandeMm = bandeDeFeuille({
feuillePx: gabarit.getBoundingClientRect().height,
contenuPx: contenu.getBoundingClientRect().height,
margeMm: marge,
});
}
// La vue choisie ; le plan, qui s'imprime à un tour, reprend celui du plan
// à l'écran quand l'aperçu couvre la soirée.
function choisirVue(vue) {
ui.reglerApercu(vue === 'plan' && $ui.apercu.tour === 'tous' ? { vue, tour: $ui.tour } : { vue });
}
async function imprimer() {
const refus = refusImpression(await imprimante.imprimer({ papier: options.papier, orientation }));
if (refus !== null) throw refus;
}
</script>
<div class="apercu" data-ecran="apercu">
<div class="barre" role="group" aria-label={t('impression.barre')}>
<h2>{t('impression.titre')}</h2>
{#if cadre !== null}
<span class="mode" data-mode-libelle="cadre">{t(cadre.cle)}</span>
{/if}
<div class="vues" role="group" aria-label={t('impression.vue.titre')}>
{#each VUES_IMPRESSION as vue (vue)}
<button type="button" data-commande="vue" data-cible={vue} aria-pressed={options.vue === vue} onclick={() => choisirVue(vue)}
>{t(cleVue(vue))}</button
>
{/each}
</div>
{#if options.vue === 'plan'}
<label class="reglage">
<span>{t('impression.tour.titre')}</span>
<select data-champ="tour" value={options.tour} onchange={(evenement) => ui.reglerApercu({ tour: Number(evenement.currentTarget.value) })}>
{#each tours as tour (tour)}
<option value={tour}>{t('impression.tour.option', { tour })}</option>
{/each}
</select>
</label>
<button type="button" data-commande="listes" aria-pressed={options.listes} onclick={() => ui.reglerApercu({ listes: !options.listes })}
>{t('impression.listes')}</button
>
<label class="reglage">
<span>{t('impression.decoupe.titre')}</span>
<select data-champ="decoupe" value={options.decoupe} onchange={(evenement) => ui.reglerApercu({ decoupe: evenement.currentTarget.value })}>
{#each DECOUPES as decoupe (decoupe)}
<option value={decoupe}>{t(cleDecoupe(decoupe))}</option>
{/each}
</select>
</label>
{:else if options.vue === 'tables'}
<button type="button" data-commande="parTour" aria-pressed={options.parTour} onclick={() => ui.reglerApercu({ parTour: !options.parTour })}
>{t('impression.parTour')}</button
>
{/if}
{#if !options.question}
<Papier />
<Commande cle="impression.commande.imprimer" nom="imprimer" executer={imprimer} />
{/if}
<Commande cle="impression.commande.fermer" nom="fermerApercu" executer={fermer} />
</div>
{#if options.question}
<div class="question">
<Papier />
</div>
{:else}
{#key cleGabarit}
<div class="gabarit" aria-hidden="true" {@attach mesurer}>
<Vue etat={sortie.etat} {options} {feuille} />
</div>
{/key}
{#if bandeMm !== null}
<div class="document" data-impression data-vue-impression={options.vue}>
<Vue etat={sortie.etat} {options} {feuille} {bandeMm} bind:orientation={orientationVue} />
</div>
{/if}
{/if}
</div>
<style>
.apercu {
display: flex;
flex-direction: column;
gap: var(--espace-4);
}
.barre {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--espace-2) var(--espace-4);
padding-bottom: var(--espace-3);
border-bottom: 1px solid var(--couleur-page-filet);
}
h2 {
margin: 0;
font-size: var(--taille-titre);
}
.mode {
font-weight: bold;
}
.vues {
display: flex;
flex-wrap: wrap;
gap: var(--espace-2);
}
.reglage {
display: inline-flex;
align-items: center;
gap: var(--espace-2);
}
button,
select {
padding: var(--espace-1) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
font-size: var(--taille-texte);
}
button {
cursor: pointer;
}
/* Une vue ou un réglage enfoncé se distingue par sa forme : gras et
souligné. */
button[aria-pressed='true'] {
font-weight: bold;
text-decoration: underline;
}
button:focus-visible,
select:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
/* La feuille gabarit se rend hors de la vue : elle se mesure, elle ne se
voit ni ne s'atteint. */
.gabarit {
position: fixed;
top: 0;
left: 0;
z-index: -1;
visibility: hidden;
pointer-events: none;
}
.document {
display: block;
}
</style>

View file

@ -0,0 +1,75 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Une feuille imprimée, une page (§ 11.7) : aux dimensions du papier dans
// son orientation, en mm, sa marge de MARGE_PAGE_MM sur chaque bord ; sa
// zone de contenu [data-contenu], ce que la vue y pose ; ce que la vue
// porte hors du contenu (bande) ; son pied [data-pied] ; et le filigrane
// BROUILLON, par-dessus, quand la feuille est un brouillon. Elle demande
// par sa propriété page la page nommée de son papier et de son
// orientation (impression.css), et se termine à l'impression par un saut
// de page. Le papier est clair dans les deux thèmes : la feuille est sous
// [data-impression], qui porte les jetons du clair, et peint son fond du
// papier et son texte de l'encre de la page.
//
// La zone de contenu mesure la hauteur utile que la découpe reçoit : la
// page, moins les marges, moins bandeMm, la bande mesurée sur la feuille
// gabarit. Sans bandeMm, la feuille est le gabarit : sa hauteur suit ce
// qu'elle porte, son contenu est vide et de hauteur nulle, et elle ne
// porte ni rang, ni filigrane. L'aperçu la mesure (bandeDeFeuille).
import { constante } from '../../geometrie/constantes.js';
import Filigrane from './Filigrane.svelte';
import PiedDePage from './PiedDePage.svelte';
import { dimensionsPage, hauteurContenu, nomPage } from './modele.js';
let { rang = null, papier, orientation, feuille, bandeMm = null, contenu = null, bande = null } = $props();
const marge = constante('MARGE_PAGE_MM');
const gabarit = $derived(bandeMm === null);
const dimensions = $derived(dimensionsPage(papier, orientation));
const hauteur = $derived(gabarit ? undefined : `${dimensions.hauteur}mm`);
const hauteurUtile = $derived(gabarit ? 0 : hauteurContenu({ papier, orientation, margeMm: marge, bandeMm }));
const page = $derived(nomPage(papier, orientation));
</script>
<section
class="feuille"
data-feuille={gabarit ? undefined : rang}
style:width="{dimensions.largeur}mm"
style:height={hauteur}
style:padding="{marge}mm"
style:page
>
<div class="contenu" data-contenu style:height="{hauteurUtile}mm">
{#if !gabarit}
{@render contenu?.()}
{/if}
</div>
{@render bande?.()}
<PiedDePage elements={feuille.elements} />
{#if feuille.filigrane && !gabarit}
<Filigrane {papier} {orientation} />
{/if}
</section>
<style>
.feuille {
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
margin: 0 auto var(--espace-5);
overflow: hidden;
border: 1px solid var(--couleur-page-filet);
background-color: var(--couleur-papier);
color: var(--couleur-page-texte);
box-shadow: var(--ombre-2);
}
.contenu {
flex: none;
min-height: 0;
overflow: hidden;
}
</style>

View file

@ -0,0 +1,34 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Les feuilles par table imprimées (§ 11.7), dans leur forme minimale :
// une feuille en portrait, titrée, sans table, qui porte le pied de la
// soirée ; l'option parTour de l'aperçu ne la change pas.
//
// Sans bandeMm, la vue rend sa feuille gabarit, que l'aperçu mesure.
import { getContext } from 'svelte';
import Feuille from './Feuille.svelte';
let { options, feuille, bandeMm = null, orientation = $bindable(null) } = $props();
const t = getContext('t');
$effect(() => {
orientation = 'portrait';
});
</script>
<Feuille rang={0} papier={options.papier} orientation="portrait" {feuille} {bandeMm}>
{#snippet contenu()}
<p class="titre">{t('impression.tables.titre')}</p>
{/snippet}
</Feuille>
<style>
.titre {
margin: 0;
font-size: var(--taille-titre);
font-weight: bold;
}
</style>

View file

@ -0,0 +1,61 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le filigrane BROUILLON d'une feuille (§ 11.7, § 14.5) : le texte de la
// traduction, au trait — aucun remplissage, un contour du jeton
// --couleur-filigrane —, par-dessus le contenu de la feuille, couché sur sa
// diagonale montante par une rotation de l'élément lui-même. Ni opacité
// partielle, ni filtre : un contour se lit sans cacher les noms dessous,
// et sa couleur relue est celle qui se peint. Le dessin couvre la page
// entière, en mm de papier, et ne reçoit aucun pointeur. Le texte reste
// un texte : il est dans la couche de texte de la page imprimée.
// data-marque le nomme au registre des marques (marques.js).
import { getContext } from 'svelte';
import { filigraneDe } from './modele.js';
let { papier, orientation } = $props();
const t = getContext('t');
const dessin = $derived(filigraneDe(papier, orientation));
</script>
<svg
class="filigrane"
data-filigrane
viewBox="0 0 {dessin.largeur} {dessin.hauteur}"
role="img"
aria-label={t('impression.filigrane')}
>
<text
data-marque="filigrane"
x={dessin.x}
y={dessin.y}
font-size={dessin.corps}
stroke-width={dessin.trait}
style:transform="rotate({dessin.angle}deg)">{t('impression.filigrane')}</text
>
</svg>
<style>
.filigrane {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: hidden;
pointer-events: none;
}
text {
fill: none;
stroke: var(--couleur-filigrane);
font-family: var(--police-interface);
font-weight: bold;
text-anchor: middle;
dominant-baseline: central;
transform-box: fill-box;
transform-origin: center;
}
</style>

View file

@ -0,0 +1,34 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La liste d'accueil imprimée (§ 11.7), dans sa forme minimale : une
// feuille en portrait, titrée, sans lignes, qui porte le pied de la
// soirée.
//
// Sans bandeMm, la vue rend sa feuille gabarit, que l'aperçu mesure.
import { getContext } from 'svelte';
import Feuille from './Feuille.svelte';
let { options, feuille, bandeMm = null, orientation = $bindable(null) } = $props();
const t = getContext('t');
$effect(() => {
orientation = 'portrait';
});
</script>
<Feuille rang={0} papier={options.papier} orientation="portrait" {feuille} {bandeMm}>
{#snippet contenu()}
<p class="titre">{t('impression.accueil.titre')}</p>
{/snippet}
</Feuille>
<style>
.titre {
margin: 0;
font-size: var(--taille-titre);
font-weight: bold;
}
</style>

View file

@ -0,0 +1,62 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le pied d'une feuille imprimée (§ 11.7, § 18.6) : ses cinq éléments —
// nom de l'événement, date, tour, horodatage du placement, version —, dans
// l'ordre que rend feuilleDe (modele.js). Chaque élément pose son libellé
// traduit, puis sa donnée dans son propre nœud : le nom et la version
// tels quels, la date et l'horodatage mis en forme par format. Les
// éléments se suivent sur une ligne et passent à la suivante quand la
// largeur manque : un nom d'événement long replie, il ne se tronque pas.
import { getContext } from 'svelte';
let { elements } = $props();
const t = getContext('t');
const format = getContext('format');
// La donnée d'un élément, dans sa forme.
function donnee({ donnee: valeur, forme }) {
if (forme === 'date') return format.date(valeur);
if (forme === 'horodatage') return format.horodatage(valeur);
return valeur;
}
</script>
<footer class="pied" data-pied>
{#each elements as element (element.element)}
<span class="element">
{#if element.libelle !== null}
<span>{t(element.libelle.cle, element.libelle.details)}</span>
{/if}
{#if element.donnee !== null}
<span class="donnee">{donnee(element)}</span>
{/if}
</span>
{/each}
</footer>
<style>
.pied {
display: flex;
flex: none;
flex-wrap: wrap;
gap: var(--espace-1) var(--espace-4);
padding-top: var(--espace-2);
border-top: 1px solid var(--couleur-page-filet);
font-size: var(--taille-texte-petit);
overflow-wrap: anywhere;
}
.element {
display: inline-flex;
flex-wrap: wrap;
gap: var(--espace-1);
min-width: 0;
}
.donnee {
font-weight: bold;
}
</style>

View file

@ -0,0 +1,62 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le plan imprimé (§ 11.7), dans sa forme minimale : la découpe du plan
// du tour en pages (decoupeDuPlan), qui reçoit la bande mesurée et choisit
// l'orientation, et une feuille par page, que son contenu titre de son
// rang. Sous le titre, la tuile de la page : un <svg> sans dessin, de
// viewBox le cadrage de la page en cm, à la taille que la découpe lui
// donne sur le papier — le cadrage à l'échelle, en mm —, soit la surface
// utile de la page, que la bande reçue diminue. L'orientation de la
// découpe remonte à l'aperçu par la propriété liée orientation : c'est
// celle qu'il demande à l'imprimante.
//
// Sans bandeMm, la vue rend sa feuille gabarit, en portrait — la largeur la
// plus étroite, où le pied replie le plus —, que l'aperçu mesure.
import { getContext } from 'svelte';
import Feuille from './Feuille.svelte';
import { decoupeDuPlan } from './modele.js';
let { etat, options, feuille, bandeMm = null, orientation = $bindable(null) } = $props();
const t = getContext('t');
const decoupe = $derived(
bandeMm === null ? null : decoupeDuPlan(etat, { papier: options.papier, bandeMm, decoupe: options.decoupe }),
);
$effect(() => {
if (decoupe !== null) orientation = decoupe.orientation;
});
</script>
{#if decoupe === null}
<Feuille papier={options.papier} orientation="portrait" {feuille} />
{:else}
{#each decoupe.pages as page (page.rang)}
<Feuille rang={page.rang} papier={options.papier} orientation={decoupe.orientation} {feuille} {bandeMm}>
{#snippet contenu()}
<p class="titre">{t('impression.plan.page', { page: page.cartouche.page, total: page.cartouche.total })}</p>
<svg
viewBox="{page.cadrage.x} {page.cadrage.y} {page.cadrage.largeur} {page.cadrage.hauteur}"
style:width="{page.cadrage.largeur * decoupe.echelle}mm"
style:height="{page.cadrage.hauteur * decoupe.echelle}mm"
aria-hidden="true"
></svg>
{/snippet}
</Feuille>
{/each}
{/if}
<style>
.titre {
margin: 0;
font-size: var(--taille-titre);
font-weight: bold;
}
svg {
display: block;
}
</style>

View file

@ -0,0 +1,258 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle de l'aperçu avant impression (§ 11.7, § 8.5, § 18.6), sans DOM :
// ce qu'une vue imprime — papier, orientation, tour —, les dimensions d'une
// page et de sa zone de contenu, la bande mesurée, la page nommée que la
// feuille d'impression déclare pour chaque papier et chaque orientation, le
// pied de cinq éléments et le filigrane d'une feuille, la source que
// l'aperçu imprime et la découpe du plan. Le modèle ne rend que des
// nombres, des codes, des clés et des données ; les composants les
// traduisent et les posent.
import { etatPlaces } from '../../application/places.js';
import { decouperPlan } from '../../geometrie/pages.js';
import { PAPIERS } from '../../geometrie/papiers.js';
import { englobantImpression, geometrieTable } from '../../geometrie/tables.js';
import { DECOUPES, VUES_IMPRESSION } from '../etat_ui.js';
/** Millimètres par pixel CSS : 96 px au pouce, 25,4 mm au pouce. */
export const MM_PAR_PX = 25.4 / 96;
/** Les papiers que l'aperçu offre, ceux de PAPIERS, dans leur ordre. */
export const PAPIERS_OFFERTS = Object.freeze(Object.keys(PAPIERS));
const ORIENTATIONS = Object.freeze(['portrait', 'paysage']);
// Le diviseur de la diagonale de la page qui donne le corps du filigrane :
// « BROUILLON » couvre environ 6 corps de Noto Sans en capitales, et tient
// alors dans la diagonale avec sa marge.
const DIVISEUR_CORPS_FILIGRANE = 9;
// L'épaisseur du contour du filigrane, en mm de papier.
const TRAIT_FILIGRANE_MM = 0.6;
function exigerPapier(papier) {
if (typeof papier !== 'string' || !Object.hasOwn(PAPIERS, papier)) {
throw new RangeError(`papier inconnu : ${JSON.stringify(papier)}`);
}
}
function exigerOrientation(orientation) {
if (!ORIENTATIONS.includes(orientation)) throw new RangeError(`orientation inconnue : ${JSON.stringify(orientation)}`);
}
const nombreFini = (valeur) => typeof valeur === 'number' && Number.isFinite(valeur);
/**
* Ce que l'aperçu imprime, tiré de son état (EtatUi.apercu) et du papier de
* la machine (EtatSeance.papier). Les listes — liste d'accueil, feuilles
* par table — s'impriment en portrait et couvrent la soirée ; le plan
* s'imprime à son tour, dans l'orientation que choisit sa découpe (null
* ici). Sans papier choisi, question est vrai : l'aperçu pose la question
* du papier avant de rien mettre en page (§ 8.5, § 11.2).
*
* @param {{vue: string, tour: number|'tous', listes: boolean, decoupe: string, parTour: boolean}} apercu
* @param {'A4'|'Lettre'|null} papier
* @returns {{vue: string, papier: string|null, orientation: 'portrait'|null, tour: number|'tous',
* listes: boolean, decoupe: string, parTour: boolean, question: boolean}}
* @throws {TypeError} sans aperçu ouvert
* @throws {RangeError} une vue ou un papier hors de leur domaine
*/
export function optionsApercu(apercu, papier) {
if (apercu === null || typeof apercu !== 'object') throw new TypeError('optionsApercu : aucun aperçu ouvert');
if (!VUES_IMPRESSION.includes(apercu.vue)) throw new RangeError(`vue inconnue : ${JSON.stringify(apercu.vue)}`);
if (papier !== null) exigerPapier(papier);
const plan = apercu.vue === 'plan';
return {
vue: apercu.vue,
papier,
orientation: plan ? null : 'portrait',
tour: plan ? apercu.tour : 'tous',
listes: apercu.listes,
decoupe: apercu.decoupe,
parTour: apercu.parTour,
question: papier === null,
};
}
/** Les tours que le plan imprimé offre : de 1 à tours, dans l'ordre. */
export function toursOfferts(tours) {
if (!Number.isSafeInteger(tours) || tours < 1) throw new RangeError(`tours : entier ≥ 1 attendu, reçu ${String(tours)}`);
return Array.from({ length: tours }, (_, rang) => rang + 1);
}
/** Les dimensions d'une page, en mm : le papier, couché en paysage. */
export function dimensionsPage(papier, orientation) {
exigerPapier(papier);
exigerOrientation(orientation);
const { largeur, hauteur } = PAPIERS[papier];
return orientation === 'portrait' ? { largeur, hauteur } : { largeur: hauteur, hauteur: largeur };
}
/** Les clés de libellé d'un papier, sous son nom en minuscules : son nom
* seul, et le choix qui le décrit. */
export function clesPapier(papier) {
exigerPapier(papier);
const cle = papier.toLowerCase();
return { nom: `impression.papier.nom.${cle}`, choix: `impression.papier.choix.${cle}` };
}
/** La clé du libellé d'une vue imprimée. */
export function cleVue(vue) {
if (!VUES_IMPRESSION.includes(vue)) throw new RangeError(`vue inconnue : ${JSON.stringify(vue)}`);
return `impression.vue.${vue}`;
}
/** La clé du libellé d'une découpe du plan. */
export function cleDecoupe(decoupe) {
if (!DECOUPES.includes(decoupe)) throw new RangeError(`découpe inconnue : ${JSON.stringify(decoupe)}`);
return `impression.decoupe.${decoupe}`;
}
/**
* Le nom de la page que la feuille d'impression déclare pour un papier et
* une orientation, le papier en minuscules : impression.css porte une règle
* @page de ce nom, à la taille du papier, sans marge, et chaque feuille la
* demande par sa propriété page.
*/
export function nomPage(papier, orientation) {
exigerPapier(papier);
exigerOrientation(orientation);
return `gtt-${papier.toLowerCase()}-${orientation}`;
}
/**
* La hauteur de la zone de contenu d'une feuille, en mm : la page, moins la
* marge de chaque bord, moins la bande — ce que la feuille porte hors de
* son contenu, mesuré sur la feuille gabarit. La découpe du plan reçoit la
* même bande : la zone de contenu est la hauteur utile d'une page.
*
* @throws {RangeError} une marge ou une bande négative ou non finie ; une
* page sans contenu
*/
export function hauteurContenu({ papier, orientation, margeMm, bandeMm }) {
const { hauteur } = dimensionsPage(papier, orientation);
for (const [nom, valeur] of [['marge', margeMm], ['bande', bandeMm]]) {
if (!nombreFini(valeur) || valeur < 0) throw new RangeError(`${nom} : nombre fini ≥ 0 attendu, reçu ${String(valeur)}`);
}
const reste = hauteur - 2 * margeMm - bandeMm;
if (!(reste > 0)) throw new RangeError(`page ${papier} ${orientation} sans contenu : ${reste} mm`);
return reste;
}
/**
* La bande d'une feuille gabarit, en mm (HAUTEUR_BANDE_IMPRESSION, § 14.1) :
* tout ce qu'elle porte hors de sa zone de contenu — pied, et ce que la vue
* y ajoute —, soit sa hauteur rendue moins celle du contenu, moins la marge
* de chaque bord, que la feuille porte en retrait.
*
* @param {{feuillePx: number, contenuPx: number, margeMm: number}} mesure px CSS
* @returns {number}
* @throws {RangeError} une mesure non finie, une bande négative
*/
export function bandeDeFeuille({ feuillePx, contenuPx, margeMm }) {
const bande = (feuillePx - contenuPx) * MM_PAR_PX - 2 * margeMm;
if (!nombreFini(bande) || bande < 0) {
throw new RangeError(`bande : la feuille de ${feuillePx} px ne porte pas son contenu de ${contenuPx} px et ses marges de ${margeMm} mm`);
}
return bande;
}
// Un élément du pied : son libellé traduit, ou null ; sa donnée, ou null ;
// la forme sous laquelle la donnée s'écrit.
const element = (nom, libelle, donnee = null, forme = 'texte') => ({ element: nom, libelle, donnee, forme });
const libelle = (cle, details = {}) => ({ cle, details });
/**
* La feuille d'un pied (piedDePage, src/application/sorties.js) : ses cinq
* éléments dans l'ordre du § 11.7 — nom de l'événement, date, tour,
* horodatage du placement, version —, chacun un libellé à traduire et une
* donnée à poser telle quelle (forme 'texte') ou à mettre en forme ('date',
* 'horodatage'), et le filigrane, qui suit brouillon (estBrouillon). Une
* date ou un horodatage absents s'écrivent par un libellé qui le dit.
*
* @param {{nom: string, date: string|null, tour: number|'tous', horodatage: string|null, version: string}} pied
* @param {boolean} brouillon
* @returns {{elements: Array<{element: string, libelle: {cle: string, details: Object}|null,
* donnee: string|null, forme: 'texte'|'date'|'horodatage'}>, filigrane: boolean}}
* @throws {TypeError} un pied hors du contrat, un brouillon non booléen
*/
export function feuilleDe(pied, brouillon) {
const { nom, date, tour, horodatage, version } = pied;
const texteOuNull = (v) => v === null || typeof v === 'string';
const valide =
typeof nom === 'string' &&
typeof version === 'string' &&
texteOuNull(date) &&
texteOuNull(horodatage) &&
(tour === 'tous' || (Number.isSafeInteger(tour) && tour >= 1));
if (!valide) throw new TypeError(`feuilleDe : pied hors du contrat ${JSON.stringify(pied)}`);
if (typeof brouillon !== 'boolean') throw new TypeError(`feuilleDe : brouillon booléen attendu, reçu ${String(brouillon)}`);
return {
elements: [
element('nom', null, nom),
date === null ? element('date', libelle('impression.pied.sansDate')) : element('date', null, date, 'date'),
element('tour', tour === 'tous' ? libelle('impression.pied.tousLesTours') : libelle('impression.pied.tour', { tour })),
horodatage === null
? element('horodatage', libelle('impression.pied.placementInconnu'))
: element('horodatage', libelle('impression.pied.placement'), horodatage, 'horodatage'),
element('version', libelle('impression.pied.version'), version),
],
filigrane: brouillon,
};
}
/**
* Le filigrane d'une page, en mm de papier : la page entière pour cadre,
* son centre, l'angle de sa diagonale montante, en degrés — négatif, le
* sens de rotation de SVG —, le corps du texte, tiré de la diagonale, et
* l'épaisseur de son contour.
*/
export function filigraneDe(papier, orientation) {
const { largeur, hauteur } = dimensionsPage(papier, orientation);
return {
largeur,
hauteur,
x: largeur / 2,
y: hauteur / 2,
angle: -(Math.atan2(hauteur, largeur) * 180) / Math.PI,
corps: Math.hypot(largeur, hauteur) / DIVISEUR_CORPS_FILIGRANE,
trait: TRAIT_FILIGRANE_MM,
};
}
/**
* La sortie que l'aperçu imprime : l'état des places de son tour
* (etatPlaces) — le tour du plan, borné aux tours de la soirée, ou le
* premier pour une vue de la soirée —, sa source et la proposition qu'elle
* désigne, null hors de la source proposition, et le tour du pied.
*
* @param {Object} charge
* @param {{tour: number|'tous'}} options celles d'optionsApercu
* @param {number|null} proposition la proposition demandée au plan
* @returns {{etat: Object, source: 'retenu'|'proposition'|'reservations', proposition: number|null,
* tour: number|'tous'}}
*/
export function sortieApercu(charge, { tour }, proposition) {
const tourEtat = tour === 'tous' ? 1 : Math.min(tour, charge.evenement.tours);
const etat = etatPlaces(charge, { tour: tourEtat, proposition });
return {
etat,
source: etat.source,
proposition: etat.source === 'proposition' ? etat.proposition : null,
tour: tour === 'tous' ? 'tous' : tourEtat,
};
}
/**
* La découpe du plan d'un tour en pages (decouperPlan, § 11.7) : les
* géométries des tables de l'état des places, à leur capacité, leur
* englobant, et la bande mesurée sur la feuille gabarit.
*
* @param {Object} etat EtatPlaces
* @param {{papier: string, bandeMm: number, decoupe: string}} options
*/
export function decoupeDuPlan(etat, { papier, bandeMm, decoupe }) {
const geometries = etat.tables.map((table) => geometrieTable(table, table.capacite));
return decouperPlan(englobantImpression(geometries), { papier, bandeMm, mode: decoupe, geometries });
}

View file

@ -0,0 +1,273 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle de l'aperçu avant impression (§ 11.7, § 8.5, § 18.6), sans DOM :
// les options d'une vue, la feuille et son pied, la bande mesurée, les pages
// nommées de la feuille d'impression et le filigrane. Les libellés que le
// modèle désigne existent, en français comme en langue témoin.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from '../../../test/lanceur.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { etatPlaces } from '../../application/places.js';
import { MARQUE_TEMOIN, creerTraduction } from '../../application/traduction.js';
import { constante } from '../../geometrie/constantes.js';
import { decouperPlan } from '../../geometrie/pages.js';
import { PAPIERS } from '../../geometrie/papiers.js';
import { englobantImpression, geometrieTable } from '../../geometrie/tables.js';
import { texteLivre } from '../../demo/livrees.js';
import { analyser } from '../../stockage/document.js';
import { TABLES_INTERFACE } from '../libelles.js';
import {
MM_PAR_PX,
PAPIERS_OFFERTS,
bandeDeFeuille,
clesPapier,
cleDecoupe,
cleVue,
decoupeDuPlan,
dimensionsPage,
feuilleDe,
filigraneDe,
hauteurContenu,
nomPage,
optionsApercu,
sortieApercu,
toursOfferts,
} from './modele.js';
const APERCU = Object.freeze({ vue: 'plan', tour: 2, listes: true, decoupe: 'auto', parTour: false, retour: 'plan' });
const PIED = Object.freeze({ nom: 'Soirée des Merles', date: '2026-10-09', tour: 2, horodatage: '2026-10-09T19:00:00-04:00', version: 'v' });
const petite = () => analyser(texteLivre('petite')).charge;
// La petite démonstration et une proposition écrite à la main, d'un seul
// tour répété : chaque table garde ses trois personnes.
function avecProposition() {
const charge = structuredClone(petite());
const tables = charge.tables.map(({ id }) => id);
const sieges = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]];
charge.propositions = [
{
id: 1,
graine: 1,
arret: 300,
historique: 1000,
produitVersion: 'v',
siegesAttribues: false,
tables,
capacites: tables.map(() => 3),
tours: charge.evenement.tours,
participants: sieges.flat(),
placement: Array.from({ length: charge.evenement.tours }, () => ({ sieges: sieges.map((s) => [...s]), reserve: [] })),
},
];
charge.prochainsIds.proposition = 2;
return charge;
}
describe('optionsApercu : ce que l’aperçu imprime', () => {
test('le plan : son tour, ses listes, sa découpe ; l’orientation, celle que la découpe choisira (null)', () => {
assert.deepEqual(optionsApercu(APERCU, 'A4'), {
vue: 'plan', papier: 'A4', orientation: null, tour: 2, listes: true, decoupe: 'auto', parTour: false, question: false,
});
});
test('les listes s’impriment en portrait et couvrent la soirée, quel que soit le tour du plan', () => {
for (const vue of ['accueil', 'tables']) {
const options = optionsApercu({ ...APERCU, vue, parTour: true }, 'Lettre');
assert.equal(options.orientation, 'portrait', vue);
assert.equal(options.tour, 'tous', vue);
assert.equal(options.papier, 'Lettre', vue);
assert.equal(options.parTour, true, vue);
}
});
test('sans papier choisi, l’aperçu pose la question : question vraie, papier null', () => {
const options = optionsApercu(APERCU, null);
assert.equal(options.question, true);
assert.equal(options.papier, null);
assert.equal(optionsApercu(APERCU, 'A4').question, false);
});
test('un aperçu fermé, une vue ou un papier hors du domaine lèvent', () => {
assert.throws(() => optionsApercu(null, 'A4'), TypeError);
assert.throws(() => optionsApercu({ ...APERCU, vue: 'badges' }, 'A4'), RangeError);
assert.throws(() => optionsApercu(APERCU, 'A3'), RangeError);
});
});
describe('les tours que le plan imprimé offre', () => {
test('de 1 au nombre de tours de la soirée, dans l’ordre ; un nombre hors du domaine lève', () => {
assert.deepEqual(toursOfferts(4), [1, 2, 3, 4]);
assert.deepEqual(toursOfferts(1), [1]);
for (const faute of [0, 1.5, Number.NaN, '3']) assert.throws(() => toursOfferts(faute), RangeError, String(faute));
});
});
describe('les dimensions d’une page (§ 11.2, § 11.7)', () => {
test('A4 et Lettre, portrait et paysage, en mm, tirés de PAPIERS', () => {
assert.deepEqual(dimensionsPage('A4', 'portrait'), { largeur: 210, hauteur: 297 });
assert.deepEqual(dimensionsPage('A4', 'paysage'), { largeur: 297, hauteur: 210 });
assert.deepEqual(dimensionsPage('Lettre', 'portrait'), { largeur: 215.9, hauteur: 279.4 });
assert.deepEqual(dimensionsPage('Lettre', 'paysage'), { largeur: 279.4, hauteur: 215.9 });
assert.throws(() => dimensionsPage('A3', 'portrait'), RangeError);
assert.throws(() => dimensionsPage('A4', 'carre'), RangeError);
});
test('les papiers offerts sont les clés de PAPIERS, chacun nommé et décrit par sa table', () => {
assert.deepEqual(PAPIERS_OFFERTS, Object.keys(PAPIERS));
assert.deepEqual(clesPapier('A4'), { nom: 'impression.papier.nom.a4', choix: 'impression.papier.choix.a4' });
assert.deepEqual(clesPapier('Lettre'), { nom: 'impression.papier.nom.lettre', choix: 'impression.papier.choix.lettre' });
assert.throws(() => clesPapier('A3'), RangeError);
});
test('la hauteur du contenu : la page, moins la marge de chaque bord, moins la bande mesurée', () => {
assert.equal(hauteurContenu({ papier: 'A4', orientation: 'portrait', margeMm: 10, bandeMm: 7.5 }), 297 - 20 - 7.5);
assert.equal(hauteurContenu({ papier: 'Lettre', orientation: 'paysage', margeMm: 10, bandeMm: 0 }), 215.9 - 20);
assert.throws(() => hauteurContenu({ papier: 'A4', orientation: 'portrait', margeMm: 10, bandeMm: 277 }), RangeError);
assert.throws(() => hauteurContenu({ papier: 'A4', orientation: 'portrait', margeMm: 10, bandeMm: -1 }), RangeError);
assert.throws(() => hauteurContenu({ papier: 'A4', orientation: 'portrait', margeMm: 10, bandeMm: Number.NaN }), RangeError);
});
});
describe('la bande mesurée (HAUTEUR_BANDE_IMPRESSION, § 14.1)', () => {
test('ce que la feuille gabarit porte hors de son contenu, en mm : sa hauteur moins le contenu, moins les deux marges', () => {
assert.equal(MM_PAR_PX, 25.4 / 96);
const feuillePx = (20 + 12) / MM_PAR_PX;
assert.ok(Math.abs(bandeDeFeuille({ feuillePx, contenuPx: 0, margeMm: 10 }) - 12) < 1e-9);
const contenuPx = 50 / MM_PAR_PX;
assert.ok(Math.abs(bandeDeFeuille({ feuillePx: feuillePx + contenuPx, contenuPx, margeMm: 10 }) - 12) < 1e-9);
});
test('une bande négative ou non finie lève : la feuille ne porte pas ses marges', () => {
assert.throws(() => bandeDeFeuille({ feuillePx: 10, contenuPx: 0, margeMm: 10 }), RangeError);
assert.throws(() => bandeDeFeuille({ feuillePx: Number.NaN, contenuPx: 0, margeMm: 10 }), RangeError);
});
test('la constante est mesurée à l’exécution, sans valeur au registre', () => {
assert.equal(constante('HAUTEUR_BANDE_IMPRESSION'), null);
});
});
describe('les pages nommées de la feuille d’impression (impression.css)', () => {
const css = readFileSync(new URL('../impression.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
const TAILLES = { A4: 'A4', Lettre: 'letter' };
const SENS = { portrait: 'portrait', paysage: 'landscape' };
test('une page nommée par papier et orientation, à la taille du papier, sans marge', () => {
const noms = [];
for (const papier of PAPIERS_OFFERTS) {
for (const orientation of ['portrait', 'paysage']) {
const nom = nomPage(papier, orientation);
noms.push(nom);
const regle = new RegExp(`@page\\s+${nom}\\s*\\{\\s*size:\\s*${TAILLES[papier]}\\s+${SENS[orientation]};\\s*margin:\\s*0;\\s*\\}`);
assert.match(css, regle, nom);
}
}
assert.equal(new Set(noms).size, 4);
assert.throws(() => nomPage('A3', 'portrait'), RangeError);
});
test('le masquage d’impression est gardé par body:has([data-impression]) : hors de l’aperçu, rien n’est masqué', () => {
const impression = css.slice(css.indexOf('@media print'));
assert.ok(css.includes('@media print'));
for (const masque of impression.matchAll(/([^{}]*)\{[^{}]*display:\s*none/g)) {
assert.match(masque[1], /body:has\(\[data-impression\]\)/, masque[1].trim());
}
});
});
describe('feuilleDe : le pied de cinq éléments et le filigrane (§ 11.7, § 18.6)', () => {
test('nom, date, tour, horodatage du placement, version, dans cet ordre ; le filigrane suit brouillon', () => {
const feuille = feuilleDe(PIED, true);
assert.deepEqual(feuille.elements.map(({ element }) => element), ['nom', 'date', 'tour', 'horodatage', 'version']);
assert.deepEqual(feuille.elements[0], { element: 'nom', libelle: null, donnee: 'Soirée des Merles', forme: 'texte' });
assert.deepEqual(feuille.elements[1], { element: 'date', libelle: null, donnee: '2026-10-09', forme: 'date' });
assert.deepEqual(feuille.elements[2], { element: 'tour', libelle: { cle: 'impression.pied.tour', details: { tour: 2 } }, donnee: null, forme: 'texte' });
assert.deepEqual(feuille.elements[3], {
element: 'horodatage', libelle: { cle: 'impression.pied.placement', details: {} }, donnee: '2026-10-09T19:00:00-04:00', forme: 'horodatage',
});
assert.deepEqual(feuille.elements[4], { element: 'version', libelle: { cle: 'impression.pied.version', details: {} }, donnee: 'v', forme: 'texte' });
assert.equal(feuille.filigrane, true);
assert.equal(feuilleDe(PIED, false).filigrane, false);
});
test('sans date, sans horodatage, à « tous » : le libellé qui le dit, aucune donnée', () => {
const { elements } = feuilleDe({ ...PIED, date: null, horodatage: null, tour: 'tous' }, false);
assert.deepEqual(elements[1], { element: 'date', libelle: { cle: 'impression.pied.sansDate', details: {} }, donnee: null, forme: 'texte' });
assert.deepEqual(elements[2], { element: 'tour', libelle: { cle: 'impression.pied.tousLesTours', details: {} }, donnee: null, forme: 'texte' });
assert.deepEqual(elements[3], { element: 'horodatage', libelle: { cle: 'impression.pied.placementInconnu', details: {} }, donnee: null, forme: 'texte' });
});
test('un pied hors du contrat, un brouillon qui n’est pas un booléen, lèvent', () => {
assert.throws(() => feuilleDe({ ...PIED, tour: 0 }, false), TypeError);
assert.throws(() => feuilleDe({ ...PIED, nom: 3 }, false), TypeError);
assert.throws(() => feuilleDe(PIED, 'oui'), TypeError);
});
});
describe('le filigrane BROUILLON (§ 11.7, § 14.5)', () => {
test('centré sur la page, couché sur sa diagonale montante, d’un corps tiré de la diagonale', () => {
for (const [papier, orientation] of [['A4', 'portrait'], ['A4', 'paysage'], ['Lettre', 'portrait']]) {
const { largeur, hauteur } = dimensionsPage(papier, orientation);
const f = filigraneDe(papier, orientation);
assert.deepEqual([f.largeur, f.hauteur, f.x, f.y], [largeur, hauteur, largeur / 2, hauteur / 2], papier);
assert.ok(Math.abs(f.angle + (Math.atan2(hauteur, largeur) * 180) / Math.PI) < 1e-9, `${papier} ${orientation}`);
assert.ok(f.corps > 0 && f.corps < Math.hypot(largeur, hauteur) / 5, `${papier} ${orientation}`);
assert.ok(f.trait > 0, 'contour non nul');
}
});
});
describe('la sortie de l’aperçu et la découpe du plan', () => {
test('sans placement, la source des réservations, sans proposition ; le tour du plan borné aux tours de la soirée', () => {
const charge = petite();
const sortie = sortieApercu(charge, { tour: 9 }, null);
assert.equal(sortie.source, 'reservations');
assert.equal(sortie.proposition, null);
assert.equal(sortie.tour, charge.evenement.tours);
assert.deepEqual(sortie.etat, etatPlaces(charge, { tour: charge.evenement.tours }));
assert.equal(sortieApercu(charge, { tour: 'tous' }, null).tour, 'tous');
});
test('une proposition montrée est la source de la sortie, par son identifiant', () => {
const sortie = sortieApercu(avecProposition(), { tour: 1 }, 1);
assert.deepEqual([sortie.source, sortie.proposition, sortie.tour], ['proposition', 1, 1]);
});
test('la découpe du plan reçoit la bande mesurée : une bande plus haute réduit la surface utile', () => {
const etat = etatPlaces(petite(), { tour: 1 });
const geometries = etat.tables.map((table) => geometrieTable(table, table.capacite));
for (const bandeMm of [0, 12.5]) {
const attendue = decouperPlan(englobantImpression(geometries), { papier: 'A4', bandeMm, mode: 'unePage', geometries });
assert.deepEqual(decoupeDuPlan(etat, { papier: 'A4', bandeMm, decoupe: 'unePage' }), attendue, String(bandeMm));
}
const basse = decoupeDuPlan(etat, { papier: 'A4', bandeMm: 0, decoupe: 'unePage' });
const haute = decoupeDuPlan(etat, { papier: 'A4', bandeMm: 150, decoupe: 'unePage' });
assert.ok(haute.echelle < basse.echelle, `${haute.echelle} < ${basse.echelle}`);
});
});
describe('les libellés de l’aperçu (§ 14.6)', () => {
const temoin = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE], { langue: 'temoin' }).t;
const { ouverture, fermeture } = MARQUE_TEMOIN;
test('chaque clé que le modèle désigne existe et se marque en langue témoin', () => {
const cles = [
...['plan', 'accueil', 'tables'].map(cleVue),
...['auto', 'unePage', 'decoupe'].map(cleDecoupe),
...PAPIERS_OFFERTS.flatMap((papier) => Object.values(clesPapier(papier))),
...[feuilleDe(PIED, true), feuilleDe({ ...PIED, date: null, horodatage: null, tour: 'tous' }, false)].flatMap(({ elements }) =>
elements.filter(({ libelle }) => libelle !== null).map(({ libelle }) => libelle.cle),
),
];
assert.ok(cles.length >= 15);
for (const cle of cles) {
const texte = temoin(cle, { tour: 2 });
assert.ok(texte.startsWith(ouverture) && texte.endsWith(fermeture), cle);
}
assert.throws(() => cleVue('badges'), RangeError);
assert.throws(() => cleDecoupe('deux'), RangeError);
});
});

View file

@ -33,6 +33,13 @@
La hachure d'une place à pourvoir peint des traits --couleur-hachure sur le
fond de la chaise, --couleur-chaise-fond : ce sont ses deux couleurs.
Le papier est clair quel que soit le thème (§ 11.7) : le bloc du clair
porte aussi le sélecteur [data-impression], et le document imprimé de
l'aperçu redéclare sur lui-même chaque jeton du clair, à l'écran comme
sur la page. Le fond d'une feuille, --couleur-papier, et le contour du
filigrane BROUILLON, --couleur-filigrane, ne se peignent que sur ce
document : chacun garde la même valeur dans les deux thèmes.
La police de l'interface est la police embarquée, Noto Sans (§ 11.1,
§ 19.4) : deux faces, 400 et 700, dont les fichiers sont ceux de
src/polices, que la production de la planche embarque aussi. La
@ -105,7 +112,8 @@ textarea {
}
:root,
:root[data-theme='clair'] {
:root[data-theme='clair'],
[data-impression] {
color-scheme: light;
/* Page. */
@ -156,6 +164,10 @@ textarea {
--couleur-liste-texte: #1c2128;
--couleur-echelle: #1c2128;
--couleur-fantome: #b040f0;
/* Le document imprimé : le papier, et le contour du filigrane. */
--couleur-papier: #ffffff;
--couleur-filigrane: #6e6e6e;
}
:root[data-theme='sombre'] {
@ -201,4 +213,7 @@ textarea {
--couleur-liste-texte: #e3e6ea;
--couleur-echelle: #e3e6ea;
--couleur-fantome: #b040f0;
--couleur-papier: #ffffff;
--couleur-filigrane: #6e6e6e;
}

View file

@ -17,6 +17,7 @@ import { TABLE as DIAGNOSTIC } from './libelles/diagnostic.js';
import { TABLE as EVENEMENT } from './libelles/evenement.js';
import { TABLE as GENERATION } from './libelles/generation.js';
import { TABLE as IMPORT } from './libelles/import.js';
import { TABLE as IMPRESSION } from './libelles/impression.js';
import { TABLE as PARTICIPANTS } from './libelles/participants.js';
import { TABLE as PLAN } from './libelles/plan.js';
import { TABLE as PROPOSITIONS } from './libelles/propositions.js';
@ -32,6 +33,7 @@ export const TABLES_INTERFACE = Object.freeze([
EVENEMENT,
GENERATION,
IMPORT,
IMPRESSION,
PARTICIPANTS,
PLAN,
PROPOSITIONS,

View file

@ -65,12 +65,12 @@ async function tablesDuDossier() {
}
describe('libellés : les tables des écrans (§ 14.6)', () => {
test('chaque module de libelles/ exporte TABLE, une Map ; TABLES_INTERFACE les assemble, douze, par ordre alphabétique', async () => {
test('chaque module de libelles/ exporte TABLE, une Map ; TABLES_INTERFACE les assemble, treize, par ordre alphabétique', async () => {
const tables = await tablesDuDossier();
assert.equal(tables.length, 12);
assert.equal(tables.length, 13);
assert.deepEqual(tables.map(({ table }) => table), [
'accueil', 'communs', 'coquille', 'diagnostic', 'evenement', 'generation',
'import', 'participants', 'plan', 'propositions', 'raccourcis', 'tables',
'import', 'impression', 'participants', 'plan', 'propositions', 'raccourcis', 'tables',
]);
for (const { table, module } of tables) assert.ok(module.TABLE instanceof Map, table);
assert.equal(TABLES_INTERFACE.length, tables.length);

View file

@ -0,0 +1,58 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les libellés de l'aperçu avant impression et de la feuille imprimée
// (§ 11.7, § 8.5, § 11.2, § 18.6), et ceux de la question du papier, que
// l'aperçu et la section des badges partagent. Chaque clé commence par
// « impression. ». Une donnée — le nom de l'événement, une date, un
// horodatage, la version — n'entre dans aucun de ces textes : la feuille la
// pose dans son propre nœud, à côté.
export const TABLE = new Map([
// --- L'écran et sa barre (§ 11.7) ------------------------------------------
['impression.titre', 'Aperçu avant impression'],
['impression.barre', "Commandes de l'aperçu"],
['impression.commande.ouvrir', 'Imprimer…'],
['impression.commande.imprimer', 'Imprimer'],
['impression.commande.fermer', "Fermer l'aperçu"],
// --- Ce que l'aperçu imprime ----------------------------------------------
['impression.vue.titre', 'Vue imprimée'],
['impression.vue.plan', 'Plan de salle'],
['impression.vue.accueil', "Liste d'accueil"],
['impression.vue.tables', 'Feuilles par table'],
['impression.tour.titre', 'Tour imprimé'],
['impression.tour.option', ({ tour }) => `Tour ${tour}`],
['impression.listes', 'Listes de noms'],
['impression.decoupe.titre', 'Pages du plan'],
['impression.decoupe.auto', 'Découpage proposé'],
['impression.decoupe.unePage', 'Tout sur une page'],
['impression.decoupe.decoupe', 'Découper en pages'],
['impression.parTour', 'Une feuille par tour'],
// --- La question du papier (§ 8.5, § 11.2) -----------------------------------
['impression.papier.question', 'Sur quel papier imprimez-vous ?'],
[
'impression.papier.explication',
"Le format est celui du bac de l'imprimante de cette machine : il est retenu ici, et ne se redemande plus.",
],
['impression.papier.choix.a4', 'A4 (210 × 297 mm)'],
['impression.papier.choix.lettre', 'Lettre (215,9 × 279,4 mm)'],
['impression.papier.titre', 'Papier :'],
['impression.papier.nom.a4', 'A4'],
['impression.papier.nom.lettre', 'Lettre'],
['impression.papier.changer', 'Changer'],
// --- La feuille imprimée (§ 11.7, § 18.6) -----------------------------------
['impression.filigrane', 'BROUILLON'],
['impression.pied.sansDate', 'Date non fixée'],
['impression.pied.tour', ({ tour }) => `Tour ${tour}`],
['impression.pied.tousLesTours', 'Tous les tours'],
['impression.pied.placement', 'Placement du'],
['impression.pied.placementInconnu', 'Placement non daté'],
['impression.pied.version', 'Version'],
// --- Les vues, avant leur mise en page (§ 11.7) ------------------------------
['impression.plan.page', ({ page, total }) => `Plan, page ${page} sur ${total}`],
['impression.accueil.titre', "Liste d'accueil"],
['impression.tables.titre', 'Feuilles par table'],
]);

View file

@ -99,6 +99,10 @@ export const MARQUES = Object.freeze([
// Le signe du chargement d'une valeur de la liste des événements (§ 8.1) :
// un arc tracé de l'encre secondaire, sur la page.
marque('chargement', 'page-texte-secondaire', ['page-fond']),
// Le filigrane BROUILLON (§ 11.7) : un texte au trait, par-dessus le
// contenu d'une feuille imprimée, mesuré sur le papier, clair dans les
// deux thèmes.
marque('filigrane', 'filigrane', ['papier']),
]);
/** Les textes et leurs fonds, même forme, seuil 4,5 : page, secondaire, bandeau, refus,

View file

@ -18,7 +18,7 @@ const NOMS_MARQUES = [
'cadenasFerme', 'cadenasOuvert', 'anneau', 'hachure', 'remplissagePlein',
'contourConflit', 'trameAppartenance', 'trameVoisins', 'trameTable', 'pastille', 'poignee', 'gel', 'miseEnEvidence',
'selection', 'cadreLecture', 'cadreEcriture', 'cadreBloque', 'focus',
'contourTable', 'contourChaise', 'echelle', 'fantome', 'chargement',
'contourTable', 'contourChaise', 'echelle', 'fantome', 'chargement', 'filigrane',
];
// Les remplissages d'une chaise : fond, pleine, et les deux couleurs de la
@ -39,7 +39,7 @@ const FONDS_REQUIS = {
const parNom = (registre, nom) => registre.find((e) => e.nom === nom);
describe('registre des marques', () => {
test('les vingt-trois marques du contrat, noms uniques, seuil 3, au moins un fond chacune', () => {
test('les vingt-quatre marques du contrat, noms uniques, seuil 3, au moins un fond chacune', () => {
assert.equal(MARQUES.length, NOMS_MARQUES.length);
assert.deepEqual(MARQUES.map((m) => m.nom), NOMS_MARQUES);
assert.equal(new Set(MARQUES.map((m) => m.nom)).size, MARQUES.length);
@ -91,6 +91,27 @@ describe('registre des marques', () => {
});
});
describe('le filigrane BROUILLON sur le papier (§ 11.7, § 14.5)', () => {
test('une marque du registre, peinte de --couleur-filigrane sur le papier, à 3:1', () => {
const filigrane = parNom(MARQUES, 'filigrane');
assert.deepEqual({ ...filigrane, fonds: [...filigrane.fonds] }, {
nom: 'filigrane',
jeton: '--couleur-filigrane',
fonds: ['--couleur-papier'],
seuil: 3,
});
});
test('le papier est clair dans les deux thèmes : le filigrane et le papier gardent leur valeur d’un thème à l’autre, et le papier est celle du clair', () => {
const [clair, sombre] = THEMES.map((theme) => lireJetons(JETONS, theme));
for (const nom of ['--couleur-filigrane', '--couleur-papier']) {
assert.notEqual(clair.get(nom), undefined, nom);
assert.deepEqual(sombre.get(nom), clair.get(nom), nom);
}
assert.deepEqual(clair.get('--couleur-papier'), [255, 255, 255, 1]);
});
});
describe('les trois sortes de conflit (§ 7.5, § 12.7)', () => {
test('une par règle localisable, dans l’ordre du moteur : sa règle, la marque de sa trame, le jeton de sa teinte, la forme de son motif', () => {
assert.deepEqual(

View file

@ -27,8 +27,13 @@ const ECART_TEINTE = 30;
const PREFIXE_COULEUR = '--couleur-';
// Les seuls sélecteurs sous lesquels une couleur se déclare : :root, ce qui
// s'applique sans attribut, et un bloc par thème.
// s'applique sans attribut, et un bloc par thème. Le document imprimé
// (§ 11.7) redéclare les jetons du clair sur lui-même : [data-impression]
// ne se pose que sur le bloc du clair, et le papier reste clair dans les
// deux thèmes.
const SELECTEURS_COULEUR = [':root', ...THEMES.map((t) => `:root[data-theme='${t}']`)];
const SELECTEUR_CLAIR = ":root[data-theme='clair']";
const SELECTEUR_PAPIER = '[data-impression]';
/** Matrices de Machado, Oliveira et Fernandes (2009), sévérité 1, appliquées en
* RGB linéaire. Chaque ligne somme à 1 : le blanc et le noir sont des points
@ -125,12 +130,14 @@ function couleurLitterale(valeur) {
* est :root seul, ce qui s'applique sans attribut. Lève sur un thème sans
* bloc, une valeur illisible, un renvoi sans cible ou circulaire, et sur un
* bloc qui déclare une couleur sous un autre sélecteur que :root et
* :root[data-theme='clair' | 'sombre'] : dans le navigateur, il masquerait
* le jeton que la lecture mesure. */
* :root[data-theme='clair' | 'sombre'] — [data-impression] excepté, sur le
* bloc du clair seul : dans le navigateur, il masquerait le jeton que la
* lecture mesure. */
export function lireJetons(texteCss, theme) {
const tous = lireBlocs(texteCss);
for (const bloc of tous) {
const autres = bloc.selecteurs.filter((s) => !SELECTEURS_COULEUR.includes(s));
const papierClair = bloc.selecteurs.includes(SELECTEUR_CLAIR);
const autres = bloc.selecteurs.filter((s) => !SELECTEURS_COULEUR.includes(s) && !(s === SELECTEUR_PAPIER && papierClair));
const couleur = [...bloc.declarations.keys()].find((n) => n.startsWith(PREFIXE_COULEUR));
if (autres.length > 0 && couleur !== undefined) {
throw new Error(`couleur ${couleur} déclarée sous un sélecteur non lu : ${autres.join(', ')}`);

View file

@ -52,6 +52,17 @@ function hex(valeur) {
const enHex = (c) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`;
describe('lireBlocs et lireJetons', () => {
test('le bloc du clair porte aussi [data-impression] : ses couleurs se lisent comme celles du clair, le papier imprimé les redéclare', () => {
const css = [
":root, :root[data-theme='clair'], [data-impression] { --couleur-a: #ffffff; --couleur-b: #102030; }",
':root[data-theme="sombre"] { --couleur-a: #000000; --couleur-b: #a0b0c0; }',
].join('\n');
assert.deepEqual(lireBlocs(css)[0].selecteurs, [':root', ":root[data-theme='clair']", '[data-impression]']);
assert.deepEqual([...lireJetons(css, 'clair')], [['--couleur-a', [255, 255, 255, 1]], ['--couleur-b', [16, 32, 48, 1]]]);
assert.deepEqual(lireJetons(css, null), lireJetons(css, 'clair'));
assert.deepEqual([...lireJetons(css, 'sombre')], [['--couleur-a', [0, 0, 0, 1]], ['--couleur-b', [160, 176, 192, 1]]]);
});
test('lit les blocs dans leur ordre, commentaires ôtés, et les couleurs de chaque thème', () => {
const css = [
"/* { un commentaire n'ouvre aucun bloc } */",
@ -123,6 +134,17 @@ describe('lireBlocs et lireJetons', () => {
}
assert.throws(() => lireJetons(`${JETONS}\n${intrus}`, 'sombre'), /sélecteur/, intrus);
}
// Le document imprimé ne prend que les jetons du clair : [data-impression]
// seul, ou posé sur le bloc du sombre, masquerait le jeton lu.
for (const intrus of [
'[data-impression] { --couleur-sol: #ff8a80; }',
":root[data-theme='sombre'], [data-impression] { --couleur-sol: #ff8a80; --couleur-focus: #e8e8e8; }",
':root, [data-impression] { --couleur-sol: #ff8a80; }',
]) {
for (const theme of ['clair', 'sombre', null]) {
assert.throws(() => lireJetons(`${base}\n${intrus}`, theme), /sélecteur/, `${theme} : ${intrus}`);
}
}
// Un autre sélecteur sans couleur n'est pas lu, et ne lève pas.
assert.deepEqual(lireJetons(`${base}\n.plan { --espace-1: 1rem; }`, 'clair'), lireJetons(base, 'clair'));
});
@ -378,6 +400,13 @@ describe('jetons.css', () => {
assert.ok(tailles.size >= 3, `${tailles.size} tailles distinctes`);
});
test('le document imprimé prend les jetons du clair dans les deux thèmes (§ 11.7) : le bloc du clair porte [data-impression], aucun autre', () => {
const avecPapier = blocs().filter((b) => b.selecteurs.includes('[data-impression]'));
assert.equal(avecPapier.length, 1);
assert.ok(avecPapier[0].selecteurs.includes(":root[data-theme='clair']"));
assert.ok([...avecPapier[0].declarations.keys()].some((nom) => nom.startsWith('--couleur-')));
});
test('deux thèmes posés par data-theme, de mêmes jetons de couleur ; sans attribut, le clair ; aucune préférence du système lue', () => {
const clair = lireJetons(JETONS, 'clair');
const sombre = lireJetons(JETONS, 'sombre');

View file

@ -0,0 +1,137 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La question du papier dans un vrai moteur de rendu (§ 8.5, § 11.2) : le
// composant que l'aperçu et la section des badges partagent. Sans papier,
// il pose la question, un choix par papier de PAPIERS ; avec un papier, il
// le montre, « Papier : A4 », et offre de le changer. Choisir passe par
// seance.choisirPapier ; un refus s'affiche sur le choix, la question reste
// et onChoisi n'est pas appelé. Le composant est monté seul, sur une séance
// d'épreuve qui suit le contrat de magasin de la séance et relève ce qu'on
// lui demande ; l'aperçu l'éprouve sur la vraie séance
// (Apercu.navigateur.test.js).
//
// Le projet navigateur emploie expect, et test et describe de Vitest
// (§ 14.8).
import { flushSync, mount, unmount } from 'svelte';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { creerHorlogePilotee } from '../../application/horloge.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { PAPIERS } from '../../geometrie/papiers.js';
import { attendre } from '../../../test/navigateur/ecrans.js';
import { TABLES_INTERFACE } from '../libelles.js';
import Papier from './Papier.svelte';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
// Une séance d'épreuve : le contrat de magasin, le papier de l'état, et
// choisirPapier, qui relève chaque demande, puis lève refus s'il est donné,
// ou pose le papier et publie.
function seanceDEpreuve(papier, { refus = null } = {}) {
let etat = Object.freeze({ papier });
const abonnes = new Set();
const demandes = [];
return {
demandes,
subscribe(fn) {
abonnes.add(fn);
fn(etat);
return () => abonnes.delete(fn);
},
etat: () => etat,
async choisirPapier(choix) {
demandes.push(choix);
if (refus !== null) throw refus;
etat = Object.freeze({ papier: choix });
for (const fn of [...abonnes]) fn(etat);
},
};
}
const montes = [];
afterEach(() => {
for (const { composant, conteneur } of montes.splice(0)) {
unmount(composant);
conteneur.remove();
}
});
// Monte Papier dans un conteneur neuf, sur la séance donnée.
function monter(seance, props = {}) {
const conteneur = document.createElement('div');
document.body.prepend(conteneur);
const contexte = new Map([
['t', t],
['seance', seance],
['horloge', creerHorlogePilotee()],
]);
const composant = mount(Papier, { target: conteneur, props, context: contexte });
flushSync();
montes.push({ composant, conteneur });
return conteneur;
}
const texte = (element) => element.textContent.replace(/\s+/g, ' ').trim();
const choix = (conteneur, papier) => conteneur.querySelector(`[data-papier="question"] [data-cible="${papier}"] [data-commande="choisirPapier"]`);
describe('la question du papier (§ 8.5, § 11.2)', () => {
test('sans papier : la question, son explication, un choix par papier de PAPIERS, dans leur ordre, chacun décrit ; rien n’est montré comme choisi', () => {
const conteneur = monter(seanceDEpreuve(null));
const question = conteneur.querySelector('[data-papier="question"]');
expect(question).not.toBeNull();
expect(texte(question)).toContain(t('impression.papier.question'));
expect(texte(question)).toContain(t('impression.papier.explication'));
const offerts = [...question.querySelectorAll('[data-cible]')].map((e) => e.dataset.cible);
expect(offerts).toEqual(Object.keys(PAPIERS));
expect(texte(choix(conteneur, 'A4'))).toBe(t('impression.papier.choix.a4'));
expect(texte(choix(conteneur, 'Lettre'))).toBe(t('impression.papier.choix.lettre'));
expect(conteneur.querySelector('[data-papier="choisi"]')).toBeNull();
});
test('choisir demande le papier à la séance, une fois ; la question cède la place au papier choisi, et onChoisi le reçoit', async () => {
const seance = seanceDEpreuve(null);
const onChoisi = vi.fn();
const conteneur = monter(seance, { onChoisi });
choix(conteneur, 'Lettre').click();
await attendre('le papier choisi', () => conteneur.querySelector('[data-papier="choisi"]'));
// onChoisi suit l'écriture faite : il s'appelle une fois le papier rendu.
await attendre('onChoisi appelé', () => onChoisi.mock.calls.length > 0);
expect(seance.demandes).toEqual(['Lettre']);
expect(onChoisi).toHaveBeenCalledTimes(1);
expect(onChoisi).toHaveBeenCalledWith('Lettre');
expect(conteneur.querySelector('[data-papier="question"]')).toBeNull();
const choisi = texte(conteneur.querySelector('[data-papier="choisi"]'));
expect(choisi).toContain(t('impression.papier.titre'));
expect(choisi).toContain(t('impression.papier.nom.lettre'));
});
test('avec un papier : « Papier : A4 — Changer » ; changer rouvre la question, et le nouveau choix se demande', async () => {
const seance = seanceDEpreuve('A4');
const conteneur = monter(seance);
expect(conteneur.querySelector('[data-papier="question"]')).toBeNull();
const choisi = conteneur.querySelector('[data-papier="choisi"]');
expect(texte(choisi)).toContain(t('impression.papier.nom.a4'));
const changer = choisi.querySelector('[data-commande="changerPapier"]');
expect(texte(changer)).toBe(t('impression.papier.changer'));
changer.click();
await attendre('la question rouverte', () => conteneur.querySelector('[data-papier="question"]'));
choix(conteneur, 'Lettre').click();
await attendre('le papier changé', () => conteneur.querySelector('[data-papier="choisi"]'));
expect(seance.demandes).toEqual(['Lettre']);
expect(texte(conteneur.querySelector('[data-papier="choisi"]'))).toContain(t('impression.papier.nom.lettre'));
});
test('un choix refusé : le refus s’affiche sur le choix, la question reste, onChoisi n’est pas appelé', async () => {
const refus = { code: 'REGLAGES_ILLISIBLES', details: { chemin: 'reglages_locaux.json', raison: 'JSON' } };
const seance = seanceDEpreuve(null, { refus });
const onChoisi = vi.fn();
const conteneur = monter(seance, { onChoisi });
choix(conteneur, 'A4').click();
const affiche = await attendre('le refus', () => conteneur.querySelector('[data-cible="A4"] [data-refus="REGLAGES_ILLISIBLES"]'));
expect(texte(affiche.querySelector('p'))).toBe(t(refus.code, refus.details).replace(/\s+/g, ' ').trim());
expect(conteneur.querySelector('[data-papier="question"]')).not.toBeNull();
expect(onChoisi).not.toHaveBeenCalled();
expect(seance.demandes).toEqual(['A4']);
});
});

View file

@ -0,0 +1,93 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La question du papier (§ 8.5, § 11.2), que l'aperçu avant impression et
// la section des badges partagent : le papier est un réglage de la
// machine, retenu dans reglages_locaux.json, demandé à la première sortie
// et jamais ensuite. Sans papier — EtatSeance.papier null —, le composant
// pose la question, un choix par papier de PAPIERS, chacun décrit ; avec
// un papier, il le montre, « Papier : A4 », et offre « Changer », qui
// rouvre la question. Aucun papier n'est choisi pour l'opérateur.
//
// Un choix passe par seance.choisirPapier, vivant dans tout mode, sur un
// plan bloqué comme en lecture. Un refus — des réglages illisibles, une
// écriture refusée — s'affiche sur le choix cliqué, par la commande, et
// la question reste. Le choix écrit, onChoisi, quand il est donné, reçoit
// le papier : la section qui demandait une production la poursuit.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
import { PAPIERS_OFFERTS, clesPapier } from '../impression/modele.js';
let { onChoisi = null } = $props();
const t = getContext('t');
const seance = getContext('seance');
// Vrai après « Changer », jusqu'au choix suivant.
let changer = $state(false);
const papier = $derived($seance.papier);
const question = $derived(papier === null || changer);
async function choisir(choix) {
await seance.choisirPapier(choix);
changer = false;
onChoisi?.(choix);
}
</script>
{#if question}
<div class="papier question" data-papier="question">
<p class="intitule">{t('impression.papier.question')}</p>
<p class="explication">{t('impression.papier.explication')}</p>
<div class="choix">
{#each PAPIERS_OFFERTS as choix (choix)}
<div data-cible={choix}>
<Commande cle={clesPapier(choix).choix} nom="choisirPapier" executer={() => choisir(choix)} />
</div>
{/each}
</div>
</div>
{:else}
<div class="papier choisi" data-papier="choisi">
<span>{t('impression.papier.titre')}</span>
<span class="donnee">{t(clesPapier(papier).nom)}</span>
<Commande cle="impression.papier.changer" nom="changerPapier" executer={() => (changer = true)} />
</div>
{/if}
<style>
.papier {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--espace-2);
}
.question {
flex-direction: column;
align-items: flex-start;
}
p {
margin: 0;
}
.intitule {
font-weight: bold;
}
.explication {
color: var(--couleur-page-texte-secondaire);
}
.choix {
display: flex;
flex-wrap: wrap;
gap: var(--espace-2);
}
.donnee {
font-weight: bold;
}
</style>

View file

@ -705,6 +705,24 @@ describe('les gardes relèvent la faute qu’un fragment porte (§ 14.2)', () =>
expect(fautes[2]).toContain('1.00:1');
});
test('marques : un texte au trait du registre — le filigrane — se mesure à son contour sur ce qui est peint sous lui, non comme un texte ; un contour noyé dans son fond est relevé ; un texte du document imprimé ne se compare pas d’un thème à l’autre', () => {
const fragment = (fond) =>
`<div data-impression style="background:${fond};padding:8px"><p style="color:#1c2128">papier</p>` +
'<svg width="240" height="60"><text data-marque="filigrane" x="10" y="40" ' +
'style="fill:none;stroke:var(--couleur-filigrane);stroke-width:1px;font-size:30px">BROUILLON</text></svg></div>';
const lire = (fond) => avecFragment(fragment(fond), (c) => ({ marques: releverMarques(c), textes: releverTextes(c) }));
const net = lire('#ffffff');
expect(net.marques.vues).toEqual(['filigrane']);
expect(net.marques.mesures).toBe(1);
expect(net.marques.fautes).toEqual([]);
expect(net.textes.mesures).toBe(1);
expect(net.textes.fautes).toEqual([]);
expect([...net.textes.couleurs]).toEqual([]);
const noye = lire(rgb(couleurDeJeton('--couleur-filigrane')));
expect(noye.marques.fautes).toHaveLength(1);
expect(noye.marques.fautes[0]).toContain('1.00:1');
});
test('marques sous une fenêtre modale : une marque HTML de la page, qui ne s’y mesure pas, ne compte pas comme vue ; une marque dans la fenêtre, si', () => {
const { vues } = avecFragment(
'<p data-marque="gel" style="border:2px solid var(--couleur-gel)">dehors</p>' +

View file

@ -152,7 +152,12 @@ export const CHAMPS = 'input:not([type="radio"]):not([type="checkbox"]):not([typ
* Contrastes des textes rendus sous racine (§ 14.5) : chaque porteur de
* texte et chaque champ de saisie, son texte indicatif compris, sur le fond
* peint dessous ; un texte SVG par son fill sur la forme dessous. Un porteur
* que l'œil ne voit pas — couvert, rogné, de boîte vide — n'est pas mesuré.
* que l'œil ne voit pas — couvert, rogné, de boîte vide — n'est pas mesuré,
* ni un texte SVG du registre des marques — le filigrane BROUILLON, un texte
* au trait —, que releverMarques mesure à 3:1. Les textes du document
* imprimé de l'aperçu ([data-impression]) se mesurent, mais n'entrent pas
* dans couleurs : le papier est clair dans les deux thèmes (§ 11.7), et
* leurs couleurs ne diffèrent pas d'un thème à l'autre.
* → { fautes, attenuations, couleurs: Map(clé → couleurs), mesures, ratios },
* ratios : { couleur, fond, ratio } de chaque texte mesuré, le fond le plus
* défavorable, dans l'ordre des mesures. fenetre vrai : seuls les éléments
@ -165,6 +170,7 @@ export function releverTextes(racine, { fenetre = false } = {}) {
const couleurs = new Map();
const ratios = [];
let mesures = 0;
// cle null : un texte qui ne se compare pas d'un thème à l'autre.
const mesurer = (cle, nom, couleurDe, fondDe, cible) => {
let couleur;
let fonds;
@ -187,13 +193,14 @@ export function releverTextes(racine, { fenetre = false } = {}) {
} catch (erreur) {
fautes.push(`${nom} : ${erreur.message}`);
}
couleurs.set(cle, `${rgb(couleur)} sur ${fonds.map(rgb).join(' + ')}`);
if (cle !== null) couleurs.set(cle, `${rgb(couleur)} sur ${fonds.map(rgb).join(' + ')}`);
for (const a of attenuations(cible)) fautesAttenuation.push(`${nom} : ${designation(a)} ${a.propriete}: ${a.valeur}`);
};
for (const [rang, porteur] of porteursDeTexte(racine).entries()) {
const { element } = porteur;
if (fenetre && !coupeLaFenetre(element)) continue;
if (!rendu(element)) continue;
if (element instanceof SVGTextContentElement && element.hasAttribute('data-marque')) continue;
const b = element.getBoundingClientRect();
if (b.width === 0 || b.height === 0) continue;
if (!fenetre) amener(element);
@ -205,7 +212,8 @@ export function releverTextes(racine, { fenetre = false } = {}) {
if (svg && !centreDansLeDessin(element)) continue;
const texte = porteur.pseudo === null ? [...element.childNodes].filter((n) => n.nodeType === Node.TEXT_NODE).map((n) => n.textContent).join('') : '';
const nom = `${situer(element)}${porteur.pseudo ?? ''} « ${extrait(texte)} »`;
mesurer(`${rang}:${designation(porteur)}`, nom, () => couleurTexte(porteur), () => fondPeintSous(porteur), porteur);
const cle = element.closest('[data-impression]') === null ? `${rang}:${designation(porteur)}` : null;
mesurer(cle, nom, () => couleurTexte(porteur), () => fondPeintSous(porteur), porteur);
}
for (const [rang, champ] of [...racine.querySelectorAll(CHAMPS)].entries()) {
if (fenetre && !coupeLaFenetre(champ)) continue;
@ -317,11 +325,18 @@ export function bordsPeints(style) {
* voisins }. Une forme SVG : son remplissage — une trame se mesure contre
* les autres couleurs de son motif, un aplat contre ce qui est peint dessous
* en un point qu'il couvre — ou son trait, contre ce qui est dessous en un
* point du trait et contre son propre remplissage. Un élément HTML : sa
* bordure ou son fond, contre ce qui est peint sous son parent, et son fond
* propre pour une bordure. peinture null : le jeton n'est peint nulle part.
* point du trait et contre son propre remplissage. Un texte SVG au trait —
* le filigrane — : son contour, contre ce qui est peint sous le centre de sa
* boîte. Un élément HTML : sa bordure ou son fond, contre ce qui est peint
* sous son parent, et son fond propre pour une bordure. peinture null : le
* jeton n'est peint nulle part.
*/
function voisinage(element, jeton) {
if (element instanceof SVGTextContentElement) {
const { stroke } = couleursPeintes(element);
const trace = Number.parseFloat(getComputedStyle(element).strokeWidth) > 0;
return trace && stroke.some((c) => egales(c, jeton)) ? { peinture: 'trait', voisins: fondPeintSous(element) } : { peinture: null, voisins: [] };
}
if (element instanceof SVGGeometryElement) {
const motif = /^url\(/.test(getComputedStyle(element).fill);
const { fill, stroke } = couleursPeintes(element);

View file

@ -34,7 +34,7 @@ const ENTREES = [
['data-chargement', 'accueil : le signe d’un calcul en cours', ['']],
['data-chargement-liste', 'accueil : l’annonce du calcul des colonnes de la liste', ['']],
['data-choix-siege', 'plan : un siège offert par le panneau d’une table, par son rang', null],
['data-cible', 'la cible d’une commande : section, groupe ou colonne', null],
['data-cible', 'la cible d’une commande : section, groupe, colonne, vue imprimée ou papier', null],
['data-cible-redondance', 'propositions : le champ de la cible de redondance', ['']],
['data-classement', 'propositions : les lignes classées', ['']],
['data-coeurs', 'génération : le réglage des cœurs de calcul', ['']],
@ -42,6 +42,7 @@ const ENTREES = [
['data-collage', 'import : l’origine collage de l’aperçu', ['']],
['data-colonne', 'une colonne d’un tableau, par son nom', null],
['data-colonnes', 'import : l’association des colonnes', ['']],
['data-contenu', 'aperçu : la zone de contenu d’une feuille imprimée', ['']],
['data-commande', 'un bouton de commande, par son nom', null],
['data-compte', 'import : un compte du rapport, par son nom', null],
['data-conflit', 'participants : une ligne de conflit d’un groupe', ['']],
@ -61,7 +62,7 @@ const ENTREES = [
['data-doublon', 'un doublon : base de la liste, ou ligne de l’aperçu', null],
['data-doublons', 'import : les doublons de l’aperçu', ['']],
['data-duree', 'génération : la durée estimée', ['']],
['data-ecran', 'un écran de l’application', ['accueil', 'apropos', 'evenement', 'recuperation']],
['data-ecran', 'un écran de l’application', ['accueil', 'apercu', 'apropos', 'evenement', 'recuperation']],
['data-encodage', 'import : l’encodage nommé', ['']],
['data-enonce', 'un énoncé : diagnostic, ou défaut des tables', null],
['data-enregistre', 'import : le chemin des refus enregistrés', ['']],
@ -73,7 +74,9 @@ const ENTREES = [
['data-evidence', 'plan : la personne mise en évidence', null],
['data-fantome', 'plan : le fantôme d’une personne glissée', ['']],
['data-fautive', 'événement : une proposition fautive', null],
['data-feuille', 'aperçu : une feuille du document imprimé, une page, par son rang', null],
['data-fiche', 'participants : la fiche d’une personne', null],
['data-filigrane', 'aperçu : le filigrane BROUILLON d’une feuille', ['']],
['data-fichier', 'import : le fichier lu', ['']],
['data-focus-initial', 'plan : le siège qui reçoit le focus à l’ouverture du panneau', null],
['data-fusion', 'import : un groupe d’orthographes fondu', ['']],
@ -86,6 +89,7 @@ const ENTREES = [
['data-groupe-ligne', 'participants : le groupe qui compte une ligne', null],
['data-historique', 'événement : la commande de l’historique', ['']],
['data-homonyme', 'import : une ligne homonyme', null],
['data-impression', 'aperçu : le document imprimé', ['']],
['data-hors-classement', 'propositions : les lignes hors classement', ['']],
['data-issue', 'génération : l’issue de la dernière génération', ['']],
['data-legende', 'plan : la légende des conflits', ['']],
@ -112,10 +116,12 @@ const ENTREES = [
['data-orthographe', 'import : une orthographe d’un groupe', ['']],
['data-panneau', 'plan : un panneau', ['place', 'table']],
['data-panneau-diagnostic', 'plan : le panneau du diagnostic', ['']],
['data-papier', 'le papier de la machine : la question, ou le papier choisi', ['choisi', 'question']],
['data-participant', 'une personne, par son identifiant', null],
['data-partie', 'plan : une partie d’une ligne de liste ou du fantôme', null],
['data-pastille', 'plan : la pastille d’une table', null],
['data-phrase', 'une phrase : diagnostic ou défaut des tables', ['']],
['data-pied', 'aperçu : le pied d’une feuille imprimée', ['']],
['data-place-fixe', 'événement : la place fixe du cadre de mode', ['']],
['data-plan', 'plan : le <svg> du plan', ['']],
['data-poignee', 'plan : la poignée d’une table', null],
@ -153,6 +159,7 @@ const ENTREES = [
['data-verrou-seance', 'événement : la séance du verrou', ['']],
['data-version', 'accueil : la version affichée', ['']],
['data-vue', 'plan : le groupe de la vue', ['']],
['data-vue-impression', 'aperçu : la vue du document imprimé — plan, accueil ou tables', null],
];
// La Map des entrées, gelée : Object.freeze ne gèle pas le contenu d'une

View file

@ -27,6 +27,7 @@ import { rechercher } from '../../src/moteur/recherche.js';
import { serialiser } from '../../src/stockage/canonique.js';
import { analyser, capacite, configurationDepuisCharge } from '../../src/stockage/document.js';
import { SUFFIXES } from '../../src/stockage/noms.js';
import { FICHIER_REGLAGES, serialiserReglages } from '../../src/stockage/reglages_locaux.js';
import { VERSION } from '../../src/version.genere.js';
import urlCasLimites from '../../exemples/participants_cas_limites.csv?url';
import { pointer, touche } from './entrees.js';
@ -219,8 +220,8 @@ function avecTours(charge, tours) {
return suivante;
}
// La principale, sa proposition retenue, puis le plan bloqué.
function chargeBloquee() {
/** La principale, sa proposition retenue, puis le plan bloqué. */
export function chargeBloquee() {
const principale = chargePrincipale();
const charge = executer(principale, 'retenirProposition', { id: principale.propositions[0].id });
return executer(charge, 'changerEtat', { etat: 'bloque' });
@ -251,6 +252,14 @@ export const fichierEtat = (charge, base = BASE) => ({
texte: serialiser(charge, { revision: 1, produitVersion: VERSION.affichee }),
});
/** Les réglages locaux de la machine, posés dans les Documents, le
* dossier de travail des épreuves : le papier choisi, aucun cadrage. */
export const fichierReglages = (papier) => ({
racine: 'documents',
chemin: FICHIER_REGLAGES,
texte: serialiserReglages({ format: 1, papier, cadrages: [] }),
});
/** Les octets du CSV des cas limites, tels que le fichier les porte. */
export async function octetsCasLimites() {
return new Uint8Array(await (await fetch(urlCasLimites)).arrayBuffer());
@ -315,6 +324,26 @@ export async function ouvrirEvenement(contexte, charge, { ecriture = false, sect
return m;
}
/**
* Ouvre l'événement de la charge, sur la section donnée, puis l'aperçu
* avant impression par la commande « Imprimer… » de sa tête (§ 11.7), et
* rend le montage. Le papier de la machine est posé avant le montage dans
* les réglages locaux ; null n'en pose aucun, et l'aperçu s'ouvre sur la
* question du papier. Avec un papier, l'aperçu rend son document.
*
* @param {Object} contexte
* @param {Object} charge
* @param {{papier?: 'A4'|'Lettre'|null, ecriture?: boolean, section?: string, options?: Object}} [reglages]
*/
export async function ouvrirApercu(contexte, charge, { papier = 'A4', ecriture = false, section = 'plan', options = {} } = {}) {
const fichiers = [...(papier === null ? [] : [fichierReglages(papier)]), ...(options.fichiers ?? [])];
const m = await ouvrirEvenement(contexte, charge, { ecriture, section, options: { ...options, fichiers } });
await cliquer(m, '[data-ecran="evenement"] [data-commande="apercu"]');
await element(m, '[data-ecran="apercu"]');
if (papier !== null) await element(m, '[data-impression] [data-feuille]');
return m;
}
// Un écran d'événement sur la principale, sur la section donnée, au mode
// donné ; la bloquée pour 'bloque'.
function ecranDeSection(section, mode) {
@ -766,6 +795,17 @@ export const ECRANS = Object.freeze([
return m.racine;
},
},
{
nom: 'apercu',
mode: 'lecture',
async atteindre(contexte) {
// L'aperçu avant impression du plan de la principale, sa proposition
// montrée : un brouillon, chaque feuille sous son filigrane.
const m = await ouvrirApercu(contexte, chargePrincipale(), { section: 'participants' });
await element(m, '[data-impression] [data-filigrane]');
return m.racine;
},
},
{
nom: 'refus.modeLecture',
mode: 'lecture',

View file

@ -153,6 +153,10 @@ export default defineConfig({
testsToRun: () => [...RELANCES_D_UN_COMPOSANT] },
{ pattern: /(^|\/)src\/interface\/jetons\.css$/,
testsToRun: () => [...RELANCES_DES_JETONS] },
// La feuille d'impression de l'aperçu (§ 11.7) : les gardes des
// feuilles de style, et le modèle de l'aperçu, qui la lit par le disque.
{ pattern: /(^|\/)src\/interface\/impression\.css$/,
testsToRun: () => [...RELANCES_DES_JETONS, 'src/interface/impression/modele.test.js'] },
// Les documents porteurs de régions engendrées et le manifeste des
// captures, que scripts/documentation.js lit par le disque (§ 19.8).
{ pattern: /(^|\/)(README\.md|GUIDE-[^/]*\.md|doc\/captures\/manifeste\.json)$/,