[ADD] print: seating plan cut into pages, with cartouche and scale
The printed plan draws every table in seat mode with its name lists, whatever the zoom, and drops screen gestures: no selection, focus, grab handle or highlight reaches paper. Each page carries a cartouche (page p of n, column, row) and references to its neighbours; the cut uses the band measured under the footer. Print marks resolve their colour tokens from the printed document, so a dark screen theme leaves the paper light. Checked: node 3721, browser 720, node-long 101 from the index alone. --- FR --- [ADD] impression : plan découpé en pages, avec cartouche et échelle Le plan imprimé dessine chaque table en régime sièges, avec ses listes de noms, quel que soit le zoom, et laisse les gestes à l'écran : sélection, focus, poignée et mise en évidence n'atteignent pas le papier. Chaque page porte un cartouche (page p sur n, colonne, rangée) et ses renvois aux voisines ; la découpe prend la bande mesurée sous le pied. Les marques lisent leurs jetons dans le document imprimé : un thème sombre à l'écran laisse le papier clair. Vérifié : node 3721, navigateur 720, node-long 101 depuis l'index seul. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
9994858904
commit
e5d9026b2a
15 changed files with 1127 additions and 61 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 133 KiB |
File diff suppressed because one or more lines are too long
402
src/interface/impression/PlanImprime.navigateur.test.js
Normal file
402
src/interface/impression/PlanImprime.navigateur.test.js
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le plan imprimé dans un vrai moteur de rendu (§ 7.4, § 7.7, § 11.7,
|
||||
// § 13.5) : l'aperçu du plan, une feuille [data-feuille] par page de la
|
||||
// découpe, chacune portant un <svg> de page dans sa zone de contenu, et hors
|
||||
// d'elle le cartouche, l'échelle graphique et les mentions. Chaque mesure se
|
||||
// lit sur la boîte rendue et les attributs, jamais sur une classe : le
|
||||
// cadrage de chaque <svg> contre la découpe recalculée de la bande et de la
|
||||
// hauteur de ligne mesurées comme l'aperçu les mesure, la troncature des
|
||||
// listes contre celle du plan à l'écran, la barre d'échelle, les mentions,
|
||||
// la bande qui compte le cartouche, l'absence d'identifiant, de poignée et
|
||||
// de tabulation, et les marques qui se lisent en noir et blanc.
|
||||
//
|
||||
// App est montée par test/navigateur/ecrans.js sur le système de fichiers
|
||||
// simulé, au cadre de 1 366 × 700. Le projet navigateur emploie expect, et
|
||||
// test et describe de Vitest (§ 14.8).
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { texteLivre } from '../../demo/livrees.js';
|
||||
import { constante } from '../../geometrie/constantes.js';
|
||||
import { echelleGraphique } from '../../geometrie/echelle.js';
|
||||
import { TAILLE_TEXTE_LISTE_CM } from '../../geometrie/texte.js';
|
||||
import { analyser } from '../../stockage/document.js';
|
||||
import { fixerCadre } from '../../../test/navigateur/entrees.js';
|
||||
import {
|
||||
attendre,
|
||||
chargePrincipale,
|
||||
cliquer,
|
||||
creerContexte,
|
||||
fichierReglages,
|
||||
ouvrirApercu,
|
||||
ouvrirEvenement,
|
||||
} from '../../../test/navigateur/ecrans.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { CONFLITS } from '../marques.js';
|
||||
import { hauteurLigne } from '../plan/mesure_texte.js';
|
||||
import { libelleLongueur, modeleDessin } from '../plan/modele.js';
|
||||
import { nomDeCharge } from '../plan/vue_plan.js';
|
||||
import { MM_PAR_PX, bandeDeFeuille, feuilleDe, optionsApercu, sortieApercu } from './modele.js';
|
||||
import { LARGEUR_ECHELLE_MM, pagesDuPlan, renvois } from './plan.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
|
||||
// Images attendues au plus par étape.
|
||||
const IMAGES = 300;
|
||||
|
||||
// Une barre d'échelle se mesure au vingtième de millimètre (§ 7.7).
|
||||
const TOLERANCE_BARRE_MM = 0.05;
|
||||
const TOLERANCE_MM = 0.1;
|
||||
|
||||
const SVG = 'http://www.w3.org/2000/svg';
|
||||
|
||||
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-vue-impression="plan"] [data-feuille]');
|
||||
const tuileDe = (feuille) => feuille.querySelector(':scope [data-contenu] > svg');
|
||||
const mm = (px) => px * MM_PAR_PX;
|
||||
const plat = (chaine) => chaine.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Le viewBox d'un <svg>, lu de son attribut : les nombres tels que la vue
|
||||
// les a écrits, sans l'arrondi de viewBox.baseVal.
|
||||
function viewBoxDe(svg) {
|
||||
const [x, y, largeur, hauteur] = svg.getAttribute('viewBox').trim().split(/\s+/).map(Number);
|
||||
return { x, y, largeur, hauteur };
|
||||
}
|
||||
|
||||
// Les lignes de chaque liste de noms sous racine, par table : le texte de
|
||||
// chaque rangée, l'en-tête compris.
|
||||
function listesSous(racine) {
|
||||
return [...racine.querySelectorAll('text[data-liste]')].map((liste) => ({
|
||||
table: liste.dataset.liste,
|
||||
lignes: [...liste.children].map((rangee) => rangee.textContent),
|
||||
}));
|
||||
}
|
||||
|
||||
// La bande que l'aperçu a mesurée, relue comme il la mesure : sur la feuille
|
||||
// gabarit, la seule feuille hors du document imprimé.
|
||||
function bandeDuGabarit(m) {
|
||||
const contenus = tous(m, '[data-ecran="apercu"] [data-contenu]').filter((contenu) => contenu.closest('[data-impression]') === null);
|
||||
expect(contenus, 'la feuille gabarit').toHaveLength(1);
|
||||
return bandeDeFeuille({
|
||||
feuillePx: contenus[0].parentElement.getBoundingClientRect().height,
|
||||
contenuPx: contenus[0].getBoundingClientRect().height,
|
||||
margeMm: constante('MARGE_PAGE_MM'),
|
||||
});
|
||||
}
|
||||
|
||||
// La hauteur de ligne des listes, mesurée comme la vue la mesure : une
|
||||
// sonde au style des listes, sous le document imprimé.
|
||||
function hauteurDesListes(m) {
|
||||
const svg = document.createElementNS(SVG, 'svg');
|
||||
const texte = document.createElementNS(SVG, 'text');
|
||||
texte.setAttribute('font-size', String(TAILLE_TEXTE_LISTE_CM));
|
||||
texte.style.fontFamily = 'var(--police-interface)';
|
||||
svg.append(texte);
|
||||
dans(m, '[data-impression]').append(svg);
|
||||
try {
|
||||
return hauteurLigne(texte);
|
||||
} finally {
|
||||
svg.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Les pages attendues du plan que l'aperçu montre, recalculées par
|
||||
// pagesDuPlan sur la même sortie, la même bande et la même hauteur de ligne.
|
||||
function pagesAttendues(m, charge) {
|
||||
const options = optionsApercu(m.ui.etat().apercu, 'A4');
|
||||
const { etat, tour } = sortieApercu(charge, options, m.ui.etat().proposition);
|
||||
const modele = modeleDessin(etat, { listes: options.listes, nomDe: nomDeCharge(charge), impression: true });
|
||||
const pied = feuilleDe({ nom: charge.evenement.nom, date: null, tour, horodatage: null, version: 'epreuve' }, false);
|
||||
return pagesDuPlan(
|
||||
etat,
|
||||
modele.tables.map((table) => table.geometrie),
|
||||
options.decoupe,
|
||||
{ papier: 'A4', bandeMm: bandeDuGabarit(m), listes: options.listes, hauteurLigne: hauteurDesListes(m), pied },
|
||||
);
|
||||
}
|
||||
|
||||
// Change la découpe du plan par son sélecteur, et attend le document.
|
||||
async function decouper(m, decoupe) {
|
||||
const choix = dans(m, '[data-ecran="apercu"] [data-champ="decoupe"]');
|
||||
choix.value = decoupe;
|
||||
choix.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await attendre(`la découpe ${decoupe}`, () => m.ui.etat().apercu.decoupe === decoupe && feuilles(m).length > 0, IMAGES);
|
||||
}
|
||||
|
||||
// La principale, trois personnes d'une même table renommées des noms qui
|
||||
// cassent une liste : de larges capitales, une ligature, deux cents
|
||||
// caractères.
|
||||
const NOMS_LONGS = ['W'.repeat(40), '\u{00C6}'.repeat(40), `Ondine ${'Lacasse'.repeat(28)}`.slice(0, 200)];
|
||||
function chargeAuxNomsLongs() {
|
||||
const charge = chargePrincipale();
|
||||
for (const [rang, nom] of NOMS_LONGS.entries()) {
|
||||
const personne = charge.participants[rang];
|
||||
personne.prenom = null;
|
||||
personne.nom = nom;
|
||||
}
|
||||
return charge;
|
||||
}
|
||||
|
||||
describe('les pages du plan imprimé (§ 11.7)', () => {
|
||||
test('un <svg> de page par feuille, autant que de pages ; le viewBox de chacun est le cadrage de sa page, à 1e-9 près', async () => {
|
||||
for (const decoupe of ['auto', 'decoupe']) {
|
||||
const charge = analyser(texteLivre('grande')).charge;
|
||||
const m = await ouvrirApercu(contexte, charge, { section: 'participants' });
|
||||
await decouper(m, decoupe);
|
||||
const attendues = pagesAttendues(m, charge);
|
||||
const lues = feuilles(m);
|
||||
expect(lues).toHaveLength(attendues.length);
|
||||
expect(tous(m, '[data-impression] [data-contenu] > svg')).toHaveLength(attendues.length);
|
||||
for (const [rang, feuille] of lues.entries()) {
|
||||
const tuile = tuileDe(feuille);
|
||||
expect(tuile, `la tuile de la feuille ${rang}`).not.toBeNull();
|
||||
const lu = viewBoxDe(tuile);
|
||||
for (const cote of ['x', 'y', 'largeur', 'hauteur']) {
|
||||
expect(Math.abs(lu[cote] - attendues[rang].viewBox[cote]), `${decoupe}, page ${rang}, ${cote}`).toBeLessThanOrEqual(1e-9);
|
||||
}
|
||||
expect(tuile.getAttribute('role')).toBe('img');
|
||||
expect(tuile.getAttribute('aria-label')).toBe(t('impression.plan.page', { page: rang + 1, total: attendues.length }));
|
||||
expect(tuile.querySelector(':scope > g').getAttribute('aria-hidden')).toBe('true');
|
||||
}
|
||||
await contexte.defaire();
|
||||
}
|
||||
});
|
||||
|
||||
test('le cartouche rendu de chaque page, découpée : son repérage, puis un renvoi par page voisine, dans l’ordre haut, droite, bas, gauche, et rien d’autre', async () => {
|
||||
const charge = analyser(texteLivre('grande')).charge;
|
||||
const m = await ouvrirApercu(contexte, charge, { section: 'participants' });
|
||||
await decouper(m, 'decoupe');
|
||||
const attendues = pagesAttendues(m, charge);
|
||||
const lues = feuilles(m);
|
||||
expect(lues).toHaveLength(attendues.length);
|
||||
// La découpe porte des pages voisines de deux côtés au moins : sans
|
||||
// elles, un renvoi omis ou mal numéroté passerait inaperçu.
|
||||
expect(Math.max(...attendues.map(({ cartouche }) => renvois(cartouche).length)), 'des voisines sur deux côtés au moins').toBeGreaterThanOrEqual(2);
|
||||
for (const [rang, feuille] of lues.entries()) {
|
||||
const cartouches = [...feuille.querySelectorAll('[data-cartouche]')];
|
||||
expect(cartouches, `le cartouche de la feuille ${rang}`).toHaveLength(1);
|
||||
const { cartouche } = attendues[rang];
|
||||
const reperage = t('impression.plan.cartouche', cartouche);
|
||||
const textes = renvois(cartouche).map(({ cle, page }) => t(cle, { page }));
|
||||
expect(plat(cartouches[0].textContent), `feuille ${rang}`).toContain(reperage);
|
||||
expect([...cartouches[0].children].map((partie) => plat(partie.textContent)), `feuille ${rang}`).toEqual([reperage, ...textes]);
|
||||
}
|
||||
});
|
||||
|
||||
test('grande démonstration, listes montrées, A4 : découpée par défaut, chaque table sur une page au moins', async () => {
|
||||
const charge = analyser(texteLivre('grande')).charge;
|
||||
const depart = performance.now();
|
||||
const m = await ouvrirApercu(contexte, charge, { section: 'participants' });
|
||||
await attendre('les listes de chaque page', () => tous(m, '[data-impression] [data-liste] tspan').length > 0, IMAGES);
|
||||
const duree = performance.now() - depart;
|
||||
console.info(`[IM3] ouverture de l'aperçu, grande démonstration, listes montrées, A4 : ${Math.round(duree)} ms`);
|
||||
expect(m.ui.etat().apercu.listes).toBe(true);
|
||||
expect(m.ui.etat().apercu.decoupe).toBe('auto');
|
||||
expect(feuilles(m).length).toBeGreaterThan(1);
|
||||
const portees = new Set(tous(m, '[data-impression] [data-table]').map((table) => Number(table.dataset.table)));
|
||||
expect([...portees].sort((a, b) => a - b)).toEqual(charge.tables.map(({ id }) => id).sort((a, b) => a - b));
|
||||
});
|
||||
});
|
||||
|
||||
describe('la troncature des listes, celle de l’écran (§ 7.4)', () => {
|
||||
test('les noms « WWW… », « ÆÆÆ… » et de deux cents caractères s’abrègent sur le papier comme à l’écran, ligne pour ligne', async () => {
|
||||
const m = await ouvrirEvenement(contexte, chargeAuxNomsLongs(), { section: 'plan', options: { fichiers: [fichierReglages('A4')] } });
|
||||
await attendre('les listes du plan', () => tous(m, '[data-plan] [data-liste] tspan').length > 0, IMAGES);
|
||||
const ecran = new Map(listesSous(dans(m, '[data-plan]')).map(({ table, lignes }) => [table, lignes]));
|
||||
const marque = t('plan.liste.abreviation');
|
||||
expect([...ecran.values()].flat().filter((ligne) => ligne.includes(marque)).length, 'des lignes abrégées').toBeGreaterThanOrEqual(NOMS_LONGS.length);
|
||||
await cliquer(m, '[data-ecran="evenement"] [data-commande="apercu"]');
|
||||
await attendre('les listes imprimées', () => tous(m, '[data-impression] [data-liste] tspan').length > 0, IMAGES);
|
||||
const imprimees = listesSous(dans(m, '[data-impression]'));
|
||||
expect(new Set(imprimees.map(({ table }) => table))).toEqual(new Set(ecran.keys()));
|
||||
for (const { table, lignes } of imprimees) {
|
||||
expect(lignes, `table ${table}`).toEqual(ecran.get(table));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('l’échelle graphique et les mentions de chaque page (§ 7.7)', () => {
|
||||
test('la barre de chaque page mesure sa longueur à l’échelle de la page, au vingtième de mm ; sa longueur affichée est celle de libelleLongueur', async () => {
|
||||
for (const decoupe of ['auto', 'unePage', 'decoupe']) {
|
||||
const m = await ouvrirApercu(contexte, analyser(texteLivre('grande')).charge, { section: 'participants' });
|
||||
await decouper(m, decoupe);
|
||||
for (const feuille of feuilles(m)) {
|
||||
const tuile = tuileDe(feuille);
|
||||
const echelle = mm(tuile.getBoundingClientRect().width) / viewBoxDe(tuile).largeur;
|
||||
const { longueurCm } = echelleGraphique(echelle, LARGEUR_ECHELLE_MM);
|
||||
const barres = [...feuille.querySelectorAll('[data-marque="echelle"]')];
|
||||
expect(barres).toHaveLength(1);
|
||||
const barre = barres[0];
|
||||
expect(barre.closest('[data-contenu]')).toBeNull();
|
||||
const largeur = mm(barre.getBoundingClientRect().width);
|
||||
expect(Math.abs(largeur - longueurCm * echelle), `${decoupe} : ${largeur} mm pour ${longueurCm} cm`).toBeLessThanOrEqual(TOLERANCE_BARRE_MM);
|
||||
const longueur = libelleLongueur(longueurCm);
|
||||
const attendu = `${longueur.valeur.toFixed(longueur.decimales).replace('.', ',')} ${t(longueur.cle)}`;
|
||||
expect(plat(barre.parentElement.textContent)).toBe(attendu);
|
||||
}
|
||||
await contexte.defaire();
|
||||
}
|
||||
});
|
||||
|
||||
test('chaque feuille porte la mention « disposition relative » ; celle des listes, sur chaque page qui en porte, tant que les listes sont montrées', async () => {
|
||||
const m = await ouvrirApercu(contexte, analyser(texteLivre('grande')).charge, { section: 'participants' });
|
||||
const relatives = tous(m, '[data-impression] [data-mention="relative"]');
|
||||
expect(relatives.length).toBeGreaterThan(0);
|
||||
expect(relatives).toHaveLength(feuilles(m).length);
|
||||
for (const feuille of feuilles(m)) {
|
||||
const mentions = feuille.querySelectorAll('[data-mention="relative"]');
|
||||
expect(mentions).toHaveLength(1);
|
||||
expect(plat(mentions[0].textContent)).toBe(t('plan.echelle.mention'));
|
||||
const listes = feuille.querySelectorAll('[data-mention="listes"]');
|
||||
const portees = feuille.querySelectorAll('[data-table]').length > 0;
|
||||
expect(listes.length, `feuille ${feuille.dataset.feuille}`).toBe(portees ? 1 : 0);
|
||||
if (portees) expect(plat(listes[0].textContent)).toBe(t('impression.plan.listes'));
|
||||
}
|
||||
await cliquer(m, '[data-ecran="apercu"] [data-commande="listes"]');
|
||||
await attendre('les listes masquées', () => m.ui.etat().apercu.listes === false && feuilles(m).length > 0, IMAGES);
|
||||
await attendre('les mentions des listes retirées', () => tous(m, '[data-impression] [data-mention="listes"]').length === 0, IMAGES);
|
||||
expect(tous(m, '[data-impression] [data-liste]')).toHaveLength(0);
|
||||
expect(tous(m, '[data-impression] [data-mention="relative"]')).toHaveLength(feuilles(m).length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('la bande du plan (HAUTEUR_BANDE_IMPRESSION, § 14.1)', () => {
|
||||
// La bande d'une feuille rendue, en mm : la feuille, moins sa zone de
|
||||
// contenu, moins une marge à chaque bord.
|
||||
const bandeDe = (feuille) =>
|
||||
mm(feuille.getBoundingClientRect().height) -
|
||||
mm(feuille.querySelector('[data-contenu]').getBoundingClientRect().height) -
|
||||
2 * constante('MARGE_PAGE_MM');
|
||||
|
||||
test('la bande compte cartouche, mentions et barre, hors de la zone de contenu : elle dépasse le pied, et tout ce qu’elle porte tient dans la feuille', async () => {
|
||||
const m = await ouvrirApercu(contexte, analyser(texteLivre('grande')).charge, { section: 'participants' });
|
||||
const marge = constante('MARGE_PAGE_MM');
|
||||
for (const feuille of feuilles(m)) {
|
||||
for (const selecteur of ['[data-cartouche]', '[data-mention="relative"]', '[data-marque="echelle"]']) {
|
||||
const element = feuille.querySelector(selecteur);
|
||||
expect(element, selecteur).not.toBeNull();
|
||||
expect(element.closest('[data-contenu]'), selecteur).toBeNull();
|
||||
}
|
||||
const pied = feuille.querySelector('[data-pied]');
|
||||
const cartouche = feuille.querySelector('[data-cartouche]');
|
||||
const hors = mm(pied.getBoundingClientRect().height) + mm(cartouche.getBoundingClientRect().height);
|
||||
expect(bandeDe(feuille), `feuille ${feuille.dataset.feuille}`).toBeGreaterThan(hors);
|
||||
// Le pied, dernier de la bande, finit dans la feuille, au-dessus de
|
||||
// sa marge.
|
||||
const bas = feuille.getBoundingClientRect().bottom;
|
||||
expect(mm(bas - pied.getBoundingClientRect().bottom)).toBeGreaterThanOrEqual(marge - TOLERANCE_MM);
|
||||
}
|
||||
});
|
||||
|
||||
test('un pied grandi de 20 mm grandit la bande d’autant ; la tuile de chaque page a la hauteur de sa zone de contenu, avant comme après : la découpe reçoit la bande mesurée', async () => {
|
||||
const charge = analyser(texteLivre('grande')).charge;
|
||||
const m = await ouvrirApercu(contexte, charge, { section: 'participants' });
|
||||
// Chaque feuille : la hauteur de sa tuile, celle de sa zone de contenu,
|
||||
// et sa bande. La bande se mesure en portrait, sur la feuille gabarit :
|
||||
// elle ne dépend pas de l'orientation que la découpe choisit.
|
||||
const mesurer = () =>
|
||||
feuilles(m).map((feuille) => ({
|
||||
tuile: mm(tuileDe(feuille).getBoundingClientRect().height),
|
||||
contenu: mm(feuille.querySelector('[data-contenu]').getBoundingClientRect().height),
|
||||
bande: bandeDe(feuille),
|
||||
}));
|
||||
const avant = mesurer();
|
||||
const feuilleDeStyle = document.createElement('style');
|
||||
feuilleDeStyle.textContent = '[data-pied] { padding-bottom: 20mm !important; }';
|
||||
document.head.append(feuilleDeStyle);
|
||||
try {
|
||||
await cliquer(m, '[data-ecran="apercu"] [data-commande="fermerApercu"]');
|
||||
await cliquer(m, '[data-ecran="evenement"] [data-commande="apercu"]');
|
||||
await attendre('le plan', () => feuilles(m).length > 0, IMAGES);
|
||||
const apres = mesurer();
|
||||
for (const [quand, pages] of [['avant', avant], ['après', apres]]) {
|
||||
for (const [rang, page] of pages.entries()) {
|
||||
expect(Math.abs(page.tuile - page.contenu), `${quand}, page ${rang} : ${JSON.stringify(page)}`).toBeLessThanOrEqual(TOLERANCE_MM);
|
||||
}
|
||||
}
|
||||
for (const [rang, page] of apres.entries()) {
|
||||
expect(Math.abs(page.bande - avant[0].bande - 20), `page ${rang} : ${page.bande} − ${avant[0].bande}`).toBeLessThanOrEqual(TOLERANCE_MM);
|
||||
}
|
||||
} finally {
|
||||
feuilleDeStyle.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('le papier ne reçoit aucun geste (§ 11.7, § 13.5)', () => {
|
||||
test('aucun identifiant en double dans le document ; sous [data-impression], aucun id hors des motifs, ni tabindex, ni [data-poignee], ni [data-plan]', async () => {
|
||||
const m = await ouvrirApercu(contexte, analyser(texteLivre('grande')).charge, { section: 'participants' });
|
||||
await attendre('les sièges imprimés', () => tous(m, '[data-impression] [data-siege]').length > 0, IMAGES);
|
||||
expect(feuilles(m).length).toBeGreaterThan(1);
|
||||
const ids = [...document.querySelectorAll('[id]')].map((e) => e.id);
|
||||
expect(ids.filter((id, rang) => ids.indexOf(id) !== rang)).toEqual([]);
|
||||
// Les motifs se référencent par leur identifiant (url(#…)) : chaque page
|
||||
// a les siens, dans ses <defs>.
|
||||
const horsMotifs = tous(m, '[data-impression] [id]').filter((element) => element.closest('defs') === null);
|
||||
expect(horsMotifs.map((element) => element.id)).toEqual([]);
|
||||
for (const selecteur of ['[tabindex]', '[data-poignee]', '[data-plan]', '[data-table][role]', '[data-siege][role]', '[aria-activedescendant]']) {
|
||||
expect(tous(m, `[data-impression] ${selecteur}`), selecteur).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test('régime des sièges : chaque chaise de chaque table imprimée, quel que soit le zoom de l’écran', async () => {
|
||||
const charge = analyser(texteLivre('grande')).charge;
|
||||
const m = await ouvrirApercu(contexte, charge, { section: 'participants' });
|
||||
await decouper(m, 'unePage');
|
||||
const sieges = new Set(tous(m, '[data-impression] [data-siege]').map((siege) => siege.dataset.siege));
|
||||
const attendus = charge.tables.flatMap(({ id }) => {
|
||||
const places = tous(m, `[data-impression] [data-table="${id}"] [data-siege]`).length;
|
||||
expect(places, `table ${id}`).toBeGreaterThan(0);
|
||||
return Array.from({ length: places }, (_, rang) => `${id}:${rang + 1}`);
|
||||
});
|
||||
expect(sieges).toEqual(new Set(attendus));
|
||||
});
|
||||
|
||||
test('les trames de conflit, les cadenas, l’anneau et la hachure se peignent : le noir et blanc se lit par la forme', async () => {
|
||||
// La principale, sa proposition : des tables en conflit, une
|
||||
// désignation (anneau, cadenas fermé), un titre non pourvu (hachure,
|
||||
// cadenas ouvert).
|
||||
const m = await ouvrirApercu(contexte, chargePrincipale());
|
||||
await attendre('les sièges imprimés', () => tous(m, '[data-impression] [data-siege]').length > 0, IMAGES);
|
||||
const peintes = (marque) =>
|
||||
tous(m, `[data-impression] [data-marque="${marque}"]`).filter((forme) => {
|
||||
const boite = forme.getBoundingClientRect();
|
||||
return boite.width > 0 && boite.height > 0 && getComputedStyle(forme).visibility === 'visible';
|
||||
});
|
||||
const trames = CONFLITS.flatMap(({ marque }) => peintes(marque));
|
||||
expect(trames.length, 'trames de conflit').toBeGreaterThan(0);
|
||||
for (const trame of trames) expect(getComputedStyle(trame).fill).toMatch(/^url\(/);
|
||||
for (const marque of ['cadenasFerme', 'cadenasOuvert', 'anneau', 'hachure', 'contourConflit', 'pastille']) {
|
||||
expect(peintes(marque).length, marque).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('la commande de la vue', () => {
|
||||
test('le plan imprimé reprend le tour de l’écran, et le pied le nomme', async () => {
|
||||
const charge = chargePrincipale();
|
||||
expect(charge.evenement.tours).toBeGreaterThan(1);
|
||||
const m = await ouvrirApercu(contexte, charge);
|
||||
const choix = dans(m, '[data-ecran="apercu"] [data-champ="tour"]');
|
||||
choix.value = '2';
|
||||
choix.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await attendre('le tour 2', () => m.ui.etat().apercu.tour === 2 && feuilles(m).length > 0, IMAGES);
|
||||
for (const feuille of feuilles(m)) {
|
||||
expect(plat(feuille.querySelector('[data-pied]').textContent)).toContain(t('impression.pied.tour', { tour: 2 }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -2,61 +2,177 @@
|
|||
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.
|
||||
// Le plan imprimé d'un tour (§ 7.7, § 11.7, § 13.5) : une feuille par
|
||||
// page de la découpe (pagesDuPlan), chacune portant, dans sa zone de
|
||||
// contenu, un <svg> seul, de viewBox la tuile de la page en cm et de la
|
||||
// taille de la tuile sur le papier en mm. Le <svg> est une image nommée
|
||||
// (« Plan, page 2 sur 6 ») dont le contenu est caché aux technologies
|
||||
// d'assistance ; il dessine les tables de la page par PlanDessin, sur le
|
||||
// modèle du plan imprimé (modeleDessin, option impression) : régime des
|
||||
// sièges, ni poignée, ni cible, ni identifiant, ni sélection. Les listes
|
||||
// ont la largeur et la troncature de l'écran (§ 7.4).
|
||||
//
|
||||
// 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.
|
||||
// Hors de la zone de contenu, dans la bande de la feuille : le cartouche
|
||||
// de repérage — page, colonne, rangée, renvois vers les pages voisines —,
|
||||
// l'échelle graphique de la page, la mention « disposition relative » et,
|
||||
// sur une page qui porte des listes, la mention qui les dit hors
|
||||
// d'échelle. La bande se mesure ainsi tout entière : sans bandeMm, la vue
|
||||
// rend sa feuille gabarit, en portrait — la largeur la plus étroite, où
|
||||
// la bande replie le plus —, qui porte chaque élément que la bande peut
|
||||
// porter, aux numéros les plus larges (CARTOUCHE_GABARIT) ; l'aperçu la
|
||||
// mesure, et la découpe reçoit la bande mesurée.
|
||||
//
|
||||
// La hauteur d'une ligne de liste (HAUTEUR_LIGNE) se mesure sur une sonde
|
||||
// au style des listes, rendue sans taille à côté des feuilles : les blocs
|
||||
// de noms entrent dans la découpe à la hauteur qu'elle leur donne. Les
|
||||
// pages attendent la mesure. 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.
|
||||
import { getContext } from 'svelte';
|
||||
import { TAILLE_TEXTE_LISTE_CM } from '../../geometrie/texte.js';
|
||||
import { hauteurLigne, mesurable } from '../plan/mesure_texte.js';
|
||||
import { libelleLongueur, modeleDessin } from '../plan/modele.js';
|
||||
import PlanDessin from '../plan/PlanDessin.svelte';
|
||||
import { nomDeCharge } from '../plan/vue_plan.js';
|
||||
import Feuille from './Feuille.svelte';
|
||||
import { decoupeDuPlan } from './modele.js';
|
||||
import { CARTOUCHE_GABARIT, ECHELLE_GABARIT, MENTIONS_GABARIT, modeleDeLaPage, pagesDuPlan, renvois } from './plan.js';
|
||||
|
||||
let { etat, options, feuille, bandeMm = null, orientation = $bindable(null) } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const format = getContext('format');
|
||||
const seance = getContext('seance');
|
||||
|
||||
const decoupe = $derived(
|
||||
bandeMm === null ? null : decoupeDuPlan(etat, { papier: options.papier, bandeMm, decoupe: options.decoupe }),
|
||||
// La hauteur de ligne mesurée sur la sonde, en cm de dessin ; null avant
|
||||
// la mesure.
|
||||
let hauteur = $state(null);
|
||||
|
||||
const modele = $derived(modeleDessin(etat, { listes: options.listes, nomDe: nomDeCharge($seance.charge), impression: true }));
|
||||
const pages = $derived(
|
||||
bandeMm === null || hauteur === null
|
||||
? null
|
||||
: pagesDuPlan(
|
||||
etat,
|
||||
modele.tables.map((table) => table.geometrie),
|
||||
options.decoupe,
|
||||
{ papier: options.papier, bandeMm, listes: options.listes, hauteurLigne: hauteur, pied: feuille },
|
||||
),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (decoupe !== null) orientation = decoupe.orientation;
|
||||
if (pages !== null) orientation = pages[0].orientation;
|
||||
});
|
||||
|
||||
function mesurerLigne(sonde) {
|
||||
if (mesurable(sonde)) hauteur = hauteurLigne(sonde);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if decoupe === null}
|
||||
<Feuille papier={options.papier} orientation="portrait" {feuille} />
|
||||
{#snippet bandeDe(cartouche, echelle, mentions)}
|
||||
{@const longueur = libelleLongueur(echelle.longueurCm)}
|
||||
<div class="bande">
|
||||
<p class="cartouche" data-cartouche>
|
||||
<span>{t('impression.plan.cartouche', cartouche)}</span>
|
||||
{#each renvois(cartouche) as renvoi (renvoi.sens)}
|
||||
<span>{t(renvoi.cle, { page: renvoi.page })}</span>
|
||||
{/each}
|
||||
</p>
|
||||
<div class="graduation" role="img" aria-label={t('plan.echelle.nom')}>
|
||||
<div class="barre" data-marque="echelle" style:width="{echelle.largeurMm}mm"></div>
|
||||
<span class="longueur"
|
||||
><span>{format.nombre(longueur.valeur, { decimales: longueur.decimales })}</span> <span>{t(longueur.cle)}</span></span
|
||||
>
|
||||
</div>
|
||||
<p class="mention" data-mention="relative">{t('plan.echelle.mention')}</p>
|
||||
{#if mentions.listes}
|
||||
<p class="mention" data-mention="listes">{t('impression.plan.listes')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if bandeMm === null}
|
||||
<Feuille papier={options.papier} orientation="portrait" {feuille}>
|
||||
{#snippet bande()}
|
||||
{@render bandeDe(CARTOUCHE_GABARIT, ECHELLE_GABARIT, MENTIONS_GABARIT)}
|
||||
{/snippet}
|
||||
</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}
|
||||
<svg class="sonde" aria-hidden="true" width="0" height="0"
|
||||
><text font-size={TAILLE_TEXTE_LISTE_CM} {@attach mesurerLigne}></text></svg
|
||||
>
|
||||
{#if pages !== null}
|
||||
{#each pages as page (page.rang)}
|
||||
<Feuille rang={page.rang} papier={options.papier} orientation={page.orientation} {feuille} {bandeMm}>
|
||||
{#snippet contenu()}
|
||||
<svg
|
||||
viewBox="{page.viewBox.x} {page.viewBox.y} {page.viewBox.largeur} {page.viewBox.hauteur}"
|
||||
style:width="{page.taille.largeur}mm"
|
||||
style:height="{page.taille.hauteur}mm"
|
||||
role="img"
|
||||
aria-label={t('impression.plan.page', { page: page.cartouche.page, total: page.cartouche.total })}
|
||||
>
|
||||
<g aria-hidden="true">
|
||||
<PlanDessin modele={modeleDeLaPage(modele, page)} />
|
||||
</g>
|
||||
</svg>
|
||||
{/snippet}
|
||||
{#snippet bande()}
|
||||
{@render bandeDe(page.cartouche, page.echelle, page.mentions)}
|
||||
{/snippet}
|
||||
</Feuille>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.titre {
|
||||
margin: 0;
|
||||
font-size: var(--taille-titre);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* La sonde de la hauteur de ligne : le style des listes (ListeNoms),
|
||||
rendue sans taille ni place, jamais vue ni imprimée. */
|
||||
.sonde {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sonde text {
|
||||
font-family: var(--police-interface);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.bande {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
padding-top: var(--espace-2);
|
||||
font-size: var(--taille-texte-petit);
|
||||
}
|
||||
|
||||
.bande p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cartouche {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 var(--espace-3);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.graduation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
/* La barre de l'échelle se trace de ses bords : une bordure s'imprime
|
||||
sans l'option qui imprime les fonds. */
|
||||
.barre {
|
||||
box-sizing: border-box;
|
||||
height: 2mm;
|
||||
border: 1mm solid var(--couleur-echelle);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
162
src/interface/impression/plan.js
Normal file
162
src/interface/impression/plan.js
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le plan imprimé (§ 7.7, § 11.7), sans DOM : les pages du plan d'un tour,
|
||||
// chacune la tuile que la découpe (decouperPlan) lui donne, sous le pied
|
||||
// d'un seul tour, avec son cartouche de repérage, la mention « disposition
|
||||
// relative », l'échelle graphique de son échelle et, quand les listes sont
|
||||
// montrées et que la page porte une table, la mention qui dit les listes
|
||||
// hors d'échelle. Les blocs de noms entrent dans la découpe à la largeur du
|
||||
// registre, LARGEUR_BLOC_NOMS_CM, celle de l'écran, et à la hauteur que leur
|
||||
// donne la hauteur de ligne mesurée au rendu (HAUTEUR_LIGNE). Le module ne
|
||||
// rend que des nombres et des données ; PlanImprime les pose.
|
||||
import { constante } from '../../geometrie/constantes.js';
|
||||
import { echelleGraphique } from '../../geometrie/echelle.js';
|
||||
import { decouperPlan } from '../../geometrie/pages.js';
|
||||
import { englobantImpression } from '../../geometrie/tables.js';
|
||||
|
||||
/** Largeur maximale de la barre de l'échelle graphique imprimée, en mm de
|
||||
* papier. */
|
||||
export const LARGEUR_ECHELLE_MM = 40;
|
||||
|
||||
/** Le cartouche de la feuille gabarit : chaque renvoi présent, des numéros
|
||||
* et une lettre de colonne plus larges que ceux d'un plan réel. La bande
|
||||
* mesurée sur lui majore celle de toute page du plan. */
|
||||
export const CARTOUCHE_GABARIT = Object.freeze({
|
||||
page: 888,
|
||||
total: 888,
|
||||
colonne: 'WW',
|
||||
rangee: 888,
|
||||
voisins: Object.freeze({ haut: 887, droite: 887, bas: 887, gauche: 887 }),
|
||||
});
|
||||
|
||||
/** Les mentions de la feuille gabarit : toutes deux présentes. */
|
||||
export const MENTIONS_GABARIT = Object.freeze({ relative: true, listes: true });
|
||||
|
||||
/** L'échelle graphique de la feuille gabarit : une barre de largeur
|
||||
* LARGEUR_ECHELLE_MM ; sa hauteur ne dépend pas de sa longueur. */
|
||||
export const ECHELLE_GABARIT = Object.freeze({ longueurCm: 50000, largeurMm: LARGEUR_ECHELLE_MM });
|
||||
|
||||
// Les renvois d'un cartouche, dans l'ordre de la lecture du plan : le sens,
|
||||
// et la clé de son libellé.
|
||||
const RENVOIS = Object.freeze([
|
||||
['haut', 'impression.plan.renvoi.haut'],
|
||||
['droite', 'impression.plan.renvoi.droite'],
|
||||
['bas', 'impression.plan.renvoi.bas'],
|
||||
['gauche', 'impression.plan.renvoi.gauche'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* La hauteur du bloc de noms de chaque table, en cm de dessin : une ligne
|
||||
* par siège, de 1 à la capacité, plus l'en-tête d'une table gelée, plus une
|
||||
* ligne. La ligne de base de la rangée i, comptée de 1, tombe i hauteurs de
|
||||
* ligne sous le haut du bloc (ordonneesLignes, plan/modele.js) ; la ligne
|
||||
* de plus majore le jambage de la dernière, plus court que la hauteur
|
||||
* d'encre du gabarit.
|
||||
*
|
||||
* @param {{tables: Array<{id: number, capacite: number, gelee: boolean}>}} etat EtatPlaces
|
||||
* @param {number} hauteurLigne cm, HAUTEUR_LIGNE mesurée au rendu
|
||||
* @returns {Map<number, number>}
|
||||
* @throws {RangeError} une hauteur de ligne non finie ou ≤ 0
|
||||
*/
|
||||
export function hauteursListes(etat, hauteurLigne) {
|
||||
if (typeof hauteurLigne !== 'number' || !Number.isFinite(hauteurLigne) || hauteurLigne <= 0) {
|
||||
throw new RangeError(`hauteur de ligne : nombre fini > 0 attendu, reçu ${String(hauteurLigne)}`);
|
||||
}
|
||||
return new Map(etat.tables.map(({ id, capacite, gelee }) => [id, (capacite + (gelee ? 1 : 0) + 1) * hauteurLigne]));
|
||||
}
|
||||
|
||||
// Lève RangeError quand le pied n'est pas celui d'un seul tour : le plan
|
||||
// s'imprime tour par tour, et son pied le nomme.
|
||||
function exigerPiedDUnTour(pied) {
|
||||
const tour = pied?.elements?.find(({ element }) => element === 'tour');
|
||||
if (tour?.libelle?.cle !== 'impression.pied.tour') {
|
||||
throw new RangeError(`pied du plan : le tour imprimé attendu, reçu ${JSON.stringify(tour?.libelle ?? null)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Les pages du plan imprimé d'un tour (§ 11.7).
|
||||
*
|
||||
* La découpe est celle de decouperPlan, sur l'englobant d'impression des
|
||||
* géométries (englobantImpression) ; les listes montrées l'étendent de
|
||||
* leurs blocs, larges de LARGEUR_BLOC_NOMS_CM, hauts de hauteursListes. Une
|
||||
* page par tuile, dans l'ordre de la découpe :
|
||||
* - rang, cartouche, tables : ceux de la tuile ;
|
||||
* - orientation : celle de la découpe ;
|
||||
* - viewBox : le cadrage de la tuile, en cm de dessin ;
|
||||
* - taille : la tuile sur le papier, en mm, le cadrage à l'échelle ;
|
||||
* - mentions : relative, toujours ; listes, quand les listes sont montrées
|
||||
* et que la page porte une table ;
|
||||
* - echelle : la longueur ronde dont la barre tient dans largeurEchelleMm
|
||||
* à l'échelle de la découpe (echelleGraphique), et sa largeur en mm ;
|
||||
* - pied : le pied reçu, le même sur chaque page.
|
||||
*
|
||||
* @param {Object} etat EtatPlaces du tour imprimé
|
||||
* @param {import('../../geometrie/tables.js').GeometrieTable[]} geometries celles des tables de etat
|
||||
* @param {'auto'|'unePage'|'decoupe'} decoupe
|
||||
* @param {{papier: string, bandeMm: number, listes?: boolean, hauteurLigne?: number|null,
|
||||
* pied: {elements: Object[], filigrane: boolean}, largeurEchelleMm?: number}} options
|
||||
* pied : la feuille de feuilleDe (modele.js), d'un seul tour
|
||||
* @returns {Array<{rang: number, orientation: 'portrait'|'paysage',
|
||||
* viewBox: {x: number, y: number, largeur: number, hauteur: number},
|
||||
* taille: {largeur: number, hauteur: number}, tables: number[],
|
||||
* mentions: {relative: true, listes: boolean}, echelle: {longueurCm: number, largeurMm: number},
|
||||
* cartouche: Object, pied: Object}>}
|
||||
* @throws {RangeError} un pied de la soirée ; des listes montrées sans hauteur de ligne ; ce que
|
||||
* decouperPlan refuse
|
||||
*/
|
||||
export function pagesDuPlan(
|
||||
etat,
|
||||
geometries,
|
||||
decoupe,
|
||||
{ papier, bandeMm, listes = false, hauteurLigne = null, pied, largeurEchelleMm = LARGEUR_ECHELLE_MM },
|
||||
) {
|
||||
exigerPiedDUnTour(pied);
|
||||
const blocs = listes ? { largeur: constante('LARGEUR_BLOC_NOMS_CM'), hauteurs: hauteursListes(etat, hauteurLigne) } : null;
|
||||
const resultat = decouperPlan(englobantImpression(geometries, { listes: blocs }), {
|
||||
papier,
|
||||
bandeMm,
|
||||
mode: decoupe,
|
||||
geometries,
|
||||
listes: blocs,
|
||||
});
|
||||
const { longueurCm, largeurPx } = echelleGraphique(resultat.echelle, largeurEchelleMm);
|
||||
return resultat.pages.map(({ rang, cadrage, cartouche, tables }) => ({
|
||||
rang,
|
||||
orientation: resultat.orientation,
|
||||
viewBox: { ...cadrage },
|
||||
taille: { largeur: cadrage.largeur * resultat.echelle, hauteur: cadrage.hauteur * resultat.echelle },
|
||||
tables,
|
||||
mentions: { relative: true, listes: listes && tables.length > 0 },
|
||||
echelle: { longueurCm, largeurMm: largeurPx },
|
||||
cartouche,
|
||||
pied,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Les renvois d'un cartouche vers les pages voisines, dans l'ordre haut,
|
||||
* droite, bas, gauche : le sens, la clé de son libellé et le numéro de la
|
||||
* page, de 1 ; un bord du plan, sans voisine, n'en a pas.
|
||||
*
|
||||
* @param {{voisins: {haut: number|null, droite: number|null, bas: number|null, gauche: number|null}}} cartouche
|
||||
* voisins : le rang de chaque page voisine, de 0
|
||||
* @returns {Array<{sens: string, cle: string, page: number}>}
|
||||
*/
|
||||
export function renvois({ voisins }) {
|
||||
return RENVOIS.filter(([sens]) => voisins[sens] !== null).map(([sens, cle]) => ({ sens, cle, page: voisins[sens] + 1 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Le modèle de dessin d'une page : les tables du modèle que la page porte,
|
||||
* dans l'ordre du modèle — celui de la peinture —, le reste du modèle tel
|
||||
* quel. Une table hors de la tuile ne se dessine pas sur la page.
|
||||
*
|
||||
* @param {{tables: Array<{id: number}>}} modele ModeleDessin
|
||||
* @param {{tables: number[]}} page
|
||||
*/
|
||||
export function modeleDeLaPage(modele, { tables }) {
|
||||
const portees = new Set(tables);
|
||||
return { ...modele, tables: modele.tables.filter(({ id }) => portees.has(id)) };
|
||||
}
|
||||
217
src/interface/impression/plan.test.js
Normal file
217
src/interface/impression/plan.test.js
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves du plan imprimé, sans DOM (§ 7.7, § 11.7) : les pages que rend
|
||||
// pagesDuPlan sont celles de la découpe (decouperPlan), une par feuille,
|
||||
// chacune sous le pied d'un seul tour, la mention « disposition relative »
|
||||
// et l'échelle graphique de son échelle ; la mention des listes sur chaque
|
||||
// page qui en porte, quand elles sont montrées. La découpe attendue se
|
||||
// recalcule ici par decouperPlan et englobantImpression, appelées
|
||||
// directement ; la hauteur des listes, de la hauteur de ligne reçue.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { etatPlaces } from '../../application/places.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { texteLivre } from '../../demo/livrees.js';
|
||||
import { echelleGraphique } from '../../geometrie/echelle.js';
|
||||
import { decouperPlan } from '../../geometrie/pages.js';
|
||||
import { HAUTEUR_LIGNE_SUPPOSEE, englobantImpression, geometrieTable } from '../../geometrie/tables.js';
|
||||
import { analyser, creerCharge } from '../../stockage/document.js';
|
||||
import { constante } from '../../geometrie/constantes.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { modeleDessin } from '../plan/modele.js';
|
||||
import { feuilleDe } from './modele.js';
|
||||
import {
|
||||
CARTOUCHE_GABARIT,
|
||||
ECHELLE_GABARIT,
|
||||
LARGEUR_ECHELLE_MM,
|
||||
MENTIONS_GABARIT,
|
||||
hauteursListes,
|
||||
modeleDeLaPage,
|
||||
pagesDuPlan,
|
||||
renvois,
|
||||
} from './plan.js';
|
||||
|
||||
const H = HAUTEUR_LIGNE_SUPPOSEE;
|
||||
const TABLES = [...TABLES_APPLICATION, ...TABLES_INTERFACE];
|
||||
|
||||
// Le pied d'une feuille au tour donné, ou de la soirée.
|
||||
const piedAu = (tour) =>
|
||||
feuilleDe({ nom: 'Soirée des Merles', date: null, tour, horodatage: null, version: 'epreuve' }, false);
|
||||
|
||||
const demonstration = (cle) => analyser(texteLivre(cle)).charge;
|
||||
|
||||
// L'état des places du tour 1 d'une charge, ses géométries à leur capacité.
|
||||
function plan(charge, tour = 1) {
|
||||
const etat = etatPlaces(charge, { tour });
|
||||
const geometries = etat.tables.map((table) => geometrieTable(table, table.capacite));
|
||||
return { etat, geometries };
|
||||
}
|
||||
|
||||
// Une charge de tables rondes de 6 places, aux positions données (cm).
|
||||
function chargeDeTables(positions) {
|
||||
const charge = creerCharge({ id: 'epreuve:plan-imprime', nom: 'Plan imprimé', siegesParDefaut: 6, tours: 2 });
|
||||
charge.tables = positions.map(([x, y], i) => ({ id: i + 1, numero: i + 1, sieges: null, forme: 'ronde', position: { x, y } }));
|
||||
charge.prochainsIds = { participant: 1, table: positions.length + 1, proposition: 1 };
|
||||
return charge;
|
||||
}
|
||||
|
||||
// La découpe attendue, par decouperPlan appelée directement.
|
||||
function decoupeAttendue(etat, geometries, mode, { papier = 'A4', bandeMm = 12, listes = false, hauteurLigne = H } = {}) {
|
||||
const blocs = listes ? { largeur: constante('LARGEUR_BLOC_NOMS_CM'), hauteurs: hauteursListes(etat, hauteurLigne) } : null;
|
||||
return decouperPlan(englobantImpression(geometries, { listes: blocs }), { papier, bandeMm, mode, geometries, listes: blocs });
|
||||
}
|
||||
|
||||
describe('hauteursListes : la hauteur du bloc de noms de chaque table (§ 7.4, § 7.7)', () => {
|
||||
test('une ligne par siège, l’en-tête d’une table gelée, et une ligne pour le jambage de la dernière', () => {
|
||||
const etat = { tables: [{ id: 4, capacite: 6, gelee: false }, { id: 9, capacite: 3, gelee: true }] };
|
||||
const hauteurs = hauteursListes(etat, 20);
|
||||
assert.deepEqual([...hauteurs], [[4, 7 * 20], [9, 5 * 20]]);
|
||||
});
|
||||
|
||||
test('une hauteur de ligne non finie ou ≤ 0 se refuse', () => {
|
||||
const etat = { tables: [{ id: 1, capacite: 2, gelee: false }] };
|
||||
for (const h of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, null]) {
|
||||
assert.throws(() => hauteursListes(etat, h), RangeError, String(h));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagesDuPlan : une page par feuille, chacune la tuile de la découpe (§ 11.7)', () => {
|
||||
for (const [mode, listes] of [
|
||||
['auto', false],
|
||||
['auto', true],
|
||||
['decoupe', true],
|
||||
['unePage', true],
|
||||
]) {
|
||||
test(`grande démonstration, ${mode}, listes ${listes ? 'montrées' : 'masquées'} : N pages, celles de decouperPlan, tuile, cartouche et tables`, () => {
|
||||
const { etat, geometries } = plan(demonstration('grande'));
|
||||
const attendue = decoupeAttendue(etat, geometries, mode, { listes });
|
||||
const pages = pagesDuPlan(etat, geometries, mode, { papier: 'A4', bandeMm: 12, listes, hauteurLigne: H, pied: piedAu(1) });
|
||||
assert.equal(pages.length, attendue.pages.length);
|
||||
for (const [rang, page] of pages.entries()) {
|
||||
const tuile = attendue.pages[rang];
|
||||
assert.equal(page.rang, tuile.rang);
|
||||
assert.equal(page.orientation, attendue.orientation);
|
||||
assert.deepEqual(page.viewBox, tuile.cadrage);
|
||||
assert.deepEqual(page.cartouche, tuile.cartouche);
|
||||
assert.deepEqual(page.tables, tuile.tables);
|
||||
assert.deepEqual(page.taille, { largeur: tuile.cadrage.largeur * attendue.echelle, hauteur: tuile.cadrage.hauteur * attendue.echelle });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('le pied de chaque page est celui d’un seul tour, le tour imprimé', () => {
|
||||
const { etat, geometries } = plan(demonstration('grande'));
|
||||
const pied = piedAu(3);
|
||||
const pages = pagesDuPlan(etat, geometries, 'decoupe', { papier: 'A4', bandeMm: 12, listes: true, hauteurLigne: H, pied });
|
||||
assert.ok(pages.length > 1);
|
||||
for (const page of pages) {
|
||||
assert.equal(page.pied, pied);
|
||||
const tour = page.pied.elements.find(({ element }) => element === 'tour');
|
||||
assert.deepEqual(tour.libelle, { cle: 'impression.pied.tour', details: { tour: 3 } });
|
||||
}
|
||||
});
|
||||
|
||||
test('un pied de la soirée se refuse : le plan s’imprime à un tour', () => {
|
||||
const { etat, geometries } = plan(demonstration('petite'));
|
||||
assert.throws(() => pagesDuPlan(etat, geometries, 'auto', { papier: 'A4', bandeMm: 12, pied: piedAu('tous') }), RangeError);
|
||||
});
|
||||
|
||||
test('chaque page porte la mention « disposition relative » ; celle des listes, si et seulement si les listes sont montrées et que la page porte une table', () => {
|
||||
// Deux tables à 200 m l'une de l'autre : découpé, le plan a des pages
|
||||
// sans table entre elles.
|
||||
const { etat, geometries } = plan(chargeDeTables([[0, 0], [20000, 0]]));
|
||||
for (const listes of [true, false]) {
|
||||
const pages = pagesDuPlan(etat, geometries, 'decoupe', { papier: 'A4', bandeMm: 12, listes, hauteurLigne: H, pied: piedAu(1) });
|
||||
const vides = pages.filter(({ tables }) => tables.length === 0);
|
||||
assert.ok(vides.length > 0 && vides.length < pages.length, `${vides.length} pages vides sur ${pages.length}`);
|
||||
for (const page of pages) {
|
||||
assert.equal(page.mentions.relative, true);
|
||||
assert.equal(page.mentions.listes, listes && page.tables.length > 0, `page ${page.rang}, listes ${listes}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('l’échelle graphique de chaque page : la longueur ronde qui tient dans LARGEUR_ECHELLE_MM à l’échelle de la découpe, et sa largeur en mm', () => {
|
||||
for (const cle of ['petite', 'grande']) {
|
||||
const { etat, geometries } = plan(demonstration(cle));
|
||||
for (const mode of ['auto', 'unePage']) {
|
||||
const attendue = decoupeAttendue(etat, geometries, mode, { listes: true });
|
||||
const pages = pagesDuPlan(etat, geometries, mode, { papier: 'A4', bandeMm: 12, listes: true, hauteurLigne: H, pied: piedAu(1) });
|
||||
const { longueurCm, largeurPx } = echelleGraphique(attendue.echelle, LARGEUR_ECHELLE_MM);
|
||||
for (const page of pages) {
|
||||
assert.deepEqual(page.echelle, { longueurCm, largeurMm: largeurPx });
|
||||
assert.ok(page.echelle.largeurMm <= LARGEUR_ECHELLE_MM);
|
||||
assert.equal(page.echelle.largeurMm, page.echelle.longueurCm * attendue.echelle);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('grande démonstration, listes montrées, A4 : découpée par défaut, chaque table sur une page au moins', () => {
|
||||
const { etat, geometries } = plan(demonstration('grande'));
|
||||
const pages = pagesDuPlan(etat, geometries, 'auto', { papier: 'A4', bandeMm: 12, listes: true, hauteurLigne: H, pied: piedAu(1) });
|
||||
assert.ok(pages.length > 1, `${pages.length} page(s)`);
|
||||
const portees = new Set(pages.flatMap(({ tables }) => tables));
|
||||
assert.deepEqual([...portees].sort((a, b) => a - b), etat.tables.map(({ id }) => id).sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
test('les listes montrées étendent la découpe de leurs blocs : sans hauteur de ligne, elles se refusent', () => {
|
||||
const { etat, geometries } = plan(demonstration('petite'));
|
||||
assert.throws(() => pagesDuPlan(etat, geometries, 'auto', { papier: 'A4', bandeMm: 12, listes: true, pied: piedAu(1) }), RangeError);
|
||||
const sans = pagesDuPlan(etat, geometries, 'unePage', { papier: 'A4', bandeMm: 12, listes: false, pied: piedAu(1) });
|
||||
const avec = pagesDuPlan(etat, geometries, 'unePage', { papier: 'A4', bandeMm: 12, listes: true, hauteurLigne: H, pied: piedAu(1) });
|
||||
// Sur une page, le plan avec ses listes couvre plus de dessin : son
|
||||
// échelle est plus petite.
|
||||
assert.ok(avec[0].taille.largeur / avec[0].viewBox.largeur < sans[0].taille.largeur / sans[0].viewBox.largeur);
|
||||
});
|
||||
|
||||
test('un plan vide : une page, aucune table, une échelle graphique finie', () => {
|
||||
const { etat, geometries } = plan(chargeDeTables([]));
|
||||
const pages = pagesDuPlan(etat, geometries, 'auto', { papier: 'Lettre', bandeMm: 12, listes: true, hauteurLigne: H, pied: piedAu(1) });
|
||||
assert.equal(pages.length, 1);
|
||||
assert.deepEqual(pages[0].tables, []);
|
||||
assert.equal(pages[0].mentions.listes, false);
|
||||
assert.ok(Number.isFinite(pages[0].echelle.largeurMm) && pages[0].echelle.largeurMm > 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renvois, modeleDeLaPage et le gabarit de la bande', () => {
|
||||
test('renvois : les pages voisines, haut, droite, bas, gauche, numérotées de 1, les bords omis', () => {
|
||||
const cartouche = { page: 5, total: 9, colonne: 'B', rangee: 2, voisins: { haut: 1, droite: 5, bas: null, gauche: 3 } };
|
||||
assert.deepEqual(renvois(cartouche), [
|
||||
{ sens: 'haut', cle: 'impression.plan.renvoi.haut', page: 2 },
|
||||
{ sens: 'droite', cle: 'impression.plan.renvoi.droite', page: 6 },
|
||||
{ sens: 'gauche', cle: 'impression.plan.renvoi.gauche', page: 4 },
|
||||
]);
|
||||
assert.deepEqual(renvois({ ...cartouche, voisins: { haut: null, droite: null, bas: null, gauche: null } }), []);
|
||||
});
|
||||
|
||||
test('modeleDeLaPage : les tables de la page, dans l’ordre du modèle ; le reste du modèle tel quel', () => {
|
||||
const charge = demonstration('petite');
|
||||
const etat = etatPlaces(charge, { tour: 1 });
|
||||
const modele = modeleDessin(etat, { listes: true, nomDe: () => 'Iris Ombrelle', impression: true });
|
||||
const ids = modele.tables.map(({ id }) => id);
|
||||
const page = { tables: [ids[2], ids[0]] };
|
||||
const reduit = modeleDeLaPage(modele, page);
|
||||
assert.deepEqual(reduit.tables.map(({ id }) => id), [ids[0], ids[2]]);
|
||||
assert.equal(reduit.tables[0], modele.tables[0]);
|
||||
assert.deepEqual({ ...reduit, tables: null }, { ...modele, tables: null });
|
||||
});
|
||||
|
||||
test('chaque renvoi a son libellé, en français comme en langue témoin', () => {
|
||||
for (const traduction of [creerTraduction(TABLES), creerTraduction(TABLES, { langue: 'temoin' })]) {
|
||||
for (const { cle, page } of renvois(CARTOUCHE_GABARIT)) {
|
||||
assert.match(traduction.t(cle, { page }), /888|887/u, cle);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('le gabarit porte chaque élément que la bande peut porter : les quatre renvois, les deux mentions, l’échelle', () => {
|
||||
assert.equal(renvois(CARTOUCHE_GABARIT).length, 4);
|
||||
assert.deepEqual(MENTIONS_GABARIT, { relative: true, listes: true });
|
||||
assert.ok(ECHELLE_GABARIT.largeurMm > 0 && ECHELLE_GABARIT.largeurMm <= LARGEUR_ECHELLE_MM);
|
||||
});
|
||||
});
|
||||
|
|
@ -51,8 +51,22 @@ export const TABLE = new Map([
|
|||
['impression.pied.placementInconnu', 'Placement non daté'],
|
||||
['impression.pied.version', 'Version'],
|
||||
|
||||
// --- Les vues, avant leur mise en page (§ 11.7) ------------------------------
|
||||
// --- Le plan imprimé (§ 7.7, § 11.7) ---------------------------------------
|
||||
['impression.plan.page', ({ page, total }) => `Plan, page ${page} sur ${total}`],
|
||||
[
|
||||
'impression.plan.cartouche',
|
||||
({ page, total, colonne, rangee }) => `Page ${page} sur ${total} \u{2014} colonne ${colonne}, rangée ${rangee}`,
|
||||
],
|
||||
['impression.plan.renvoi.haut', ({ page }) => `au-dessus : page ${page}`],
|
||||
['impression.plan.renvoi.droite', ({ page }) => `à droite : page ${page}`],
|
||||
['impression.plan.renvoi.bas', ({ page }) => `au-dessous : page ${page}`],
|
||||
['impression.plan.renvoi.gauche', ({ page }) => `à gauche : page ${page}`],
|
||||
[
|
||||
'impression.plan.listes',
|
||||
"listes de noms hors d'échelle \u{2014} le plan à l'échelle du bâtiment s'imprime listes masquées",
|
||||
],
|
||||
|
||||
// --- Les vues, avant leur mise en page (§ 11.7) ------------------------------
|
||||
['impression.accueil.titre', "Liste d'accueil"],
|
||||
['impression.tables.titre', 'Feuilles par table'],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@
|
|||
// Le texte est en white-space: pre, pour que les espaces des parties
|
||||
// comptent comme elles ont été mesurées : le gabarit n'a aucun blanc entre
|
||||
// les balises du <text>.
|
||||
//
|
||||
// Le texte se compose en text-rendering: geometricPrecision, comme la
|
||||
// sonde qui le mesure (PlanDessin) : le moteur de rendu le met alors en
|
||||
// page à son corps du dessin, sans le recomposer au corps qu'il prend à
|
||||
// l'écran. Sans lui, la largeur mesurée suit l'échelle d'affichage — le
|
||||
// zoom du plan, l'échelle d'une page imprimée —, et une même ligne
|
||||
// s'abrège à des longueurs différentes à l'écran et sur le papier (§ 7.4).
|
||||
import { getContext } from 'svelte';
|
||||
import { TAILLE_TEXTE_LISTE_CM } from '../../geometrie/texte.js';
|
||||
import { ajusterLigne, ordonneesLignes, partiesLigne, textesLigne } from './modele.js';
|
||||
|
|
@ -52,6 +59,7 @@
|
|||
.liste {
|
||||
fill: var(--couleur-liste-texte);
|
||||
font-family: var(--police-interface);
|
||||
text-rendering: geometricPrecision;
|
||||
white-space: pre;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@
|
|||
// taille du <svg> qui porte le dessin, qu'un ResizeObserver signale : un
|
||||
// plan qui se cache perd ses mesures, et ses listes, et les reprend en se
|
||||
// montrant.
|
||||
//
|
||||
// Le modèle du plan imprimé (modele.impression, § 11.7) se dessine de
|
||||
// même, chaque table sans poignée, ni cible, ni identifiant (TablePlan) ;
|
||||
// ses listes ont la largeur et la troncature de l'écran (§ 7.4).
|
||||
import { untrack } from 'svelte';
|
||||
import { constante } from '../../geometrie/constantes.js';
|
||||
import { TAILLE_TEXTE_LISTE_CM } from '../../geometrie/texte.js';
|
||||
|
|
@ -60,7 +64,7 @@
|
|||
|
||||
<Motifs {prefixe} />
|
||||
{#each modele.tables as table (table.id)}
|
||||
<TablePlan {table} signaler={modele.signalerConflits} {prefixe} />
|
||||
<TablePlan {table} signaler={modele.signalerConflits} {prefixe} impression={modele.impression === true} />
|
||||
{/each}
|
||||
<text class="sonde" font-size={TAILLE_TEXTE_LISTE_CM} aria-hidden="true" bind:this={sonde}
|
||||
><tspan class="titre" bind:this={sondeItalique}></tspan></text
|
||||
|
|
@ -76,6 +80,7 @@
|
|||
et ne porte de texte que le temps d'une mesure. */
|
||||
.sonde {
|
||||
font-family: var(--police-interface);
|
||||
text-rendering: geometricPrecision;
|
||||
white-space: pre;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@
|
|||
// actif du clavier, la marque focus, un carré : deux formes, que la
|
||||
// couleur ne porte pas seule. Sur la chaise que le modèle désigne, ou sur
|
||||
// l'emprise de la table quand la place n'a pas de chaise dessinée.
|
||||
//
|
||||
// Imprimée (impression vrai, § 11.7), la table ne reçoit aucun geste : ni
|
||||
// identifiant, ni rôle, ni nom accessible — le <svg> de la page porte le
|
||||
// sien, et son contenu est caché aux technologies d'assistance —, ni
|
||||
// poignée, ni cible. Le plateau, les chaises, les marques, les trames et
|
||||
// la pastille sont ceux de l'écran : le noir et blanc se lit par la forme.
|
||||
import { getContext } from 'svelte';
|
||||
import { RAYON_POIGNEE } from '../../geometrie/tables.js';
|
||||
import {
|
||||
|
|
@ -56,7 +62,7 @@
|
|||
texteLigne,
|
||||
} from './modele.js';
|
||||
|
||||
let { table, signaler, prefixe } = $props();
|
||||
let { table, signaler, prefixe, impression = false } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
|
||||
|
|
@ -78,7 +84,12 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<g id="table-{table.id}" data-table={table.id} role="group" aria-label={t(table.nomAccessible.cle, table.nomAccessible.details)}>
|
||||
<g
|
||||
id={impression ? undefined : `table-${table.id}`}
|
||||
data-table={table.id}
|
||||
role={impression ? undefined : 'group'}
|
||||
aria-label={impression ? undefined : t(table.nomAccessible.cle, table.nomAccessible.details)}
|
||||
>
|
||||
{#if table.regime === 'table'}
|
||||
{#if formes.ronde}
|
||||
<circle class="emprise" data-marque="contourChaise" {...formes.emprise} />
|
||||
|
|
@ -116,10 +127,10 @@
|
|||
{/if}
|
||||
{#each table.chaises as chaise (chaise.siege)}
|
||||
<g
|
||||
id="siege-{table.id}-{chaise.siege}"
|
||||
id={impression ? undefined : `siege-${table.id}-${chaise.siege}`}
|
||||
data-siege="{table.id}:{chaise.siege}"
|
||||
role="img"
|
||||
aria-label={texteLigne(table.lignes[chaise.siege - 1], t)}
|
||||
role={impression ? undefined : 'img'}
|
||||
aria-label={impression ? undefined : texteLigne(table.lignes[chaise.siege - 1], t)}
|
||||
>
|
||||
<circle
|
||||
class="chaise"
|
||||
|
|
@ -146,11 +157,15 @@
|
|||
{#if chaise.focus}
|
||||
<rect class="focus" data-marque="focus" {...carreAutour(chaise, chaise.rayon + ECART_FOCUS)} />
|
||||
{/if}
|
||||
<circle class="cible" cx={chaise.x} cy={chaise.y} r={table.geometrie.rayonCible} />
|
||||
{#if !impression}
|
||||
<circle class="cible" cx={chaise.x} cy={chaise.y} r={table.geometrie.rayonCible} />
|
||||
{/if}
|
||||
</g>
|
||||
{/each}
|
||||
</g>
|
||||
<circle class="poignee" data-poignee={table.id} data-marque="poignee" cx={poignee.x} cy={poignee.y} r={RAYON_POIGNEE} />
|
||||
{#if !impression}
|
||||
<circle class="poignee" data-poignee={table.id} data-marque="poignee" cx={poignee.x} cy={poignee.y} r={RAYON_POIGNEE} />
|
||||
{/if}
|
||||
{#if table.pastille > 0}
|
||||
<g class="pastille" data-pastille={table.id}>
|
||||
<circle class="disque" data-marque="pastille" cx={formes.pastille.x} cy={formes.pastille.y} r={RAYON_PASTILLE} />
|
||||
|
|
|
|||
|
|
@ -314,12 +314,13 @@ function porteurDe(place, id, regime) {
|
|||
}
|
||||
|
||||
// Le dessin d'une table de l'état des places au zoom k ; places, le nombre
|
||||
// de sièges que la poignée montre, sinon sa capacité.
|
||||
function tableDessin(etatTable, { k, miseEnEvidence, nomDe, selection = null, actif = null }, places = etatTable.capacite) {
|
||||
// de sièges que la poignée montre, sinon sa capacité. Imprimée, la table
|
||||
// est en régime sièges, quel que soit k.
|
||||
function tableDessin(etatTable, { k, miseEnEvidence, nomDe, selection = null, actif = null, impression = false }, places = etatTable.capacite) {
|
||||
const { id, numero, gelee, surchargee, conflits } = etatTable;
|
||||
const geometrie = geometrieTable(etatTable, places);
|
||||
const trames = conflits === null ? [] : conflits.regles.map(({ regle }) => ({ ...SORTE_DE_REGLE.get(regle) }));
|
||||
const regime = regimeCibles(k, geometrie);
|
||||
const regime = impression ? 'sieges' : regimeCibles(k, geometrie);
|
||||
const pastille = conflits === null ? 0 : conflits.nombre;
|
||||
const etatChaises = chaisesA(etatTable, places);
|
||||
const selectionnee = porteurDe(selection, id, regime);
|
||||
|
|
@ -390,24 +391,34 @@ function tableDessin(etatTable, { k, miseEnEvidence, nomDe, selection = null, ac
|
|||
* accessible —, ses chaises 1 à places, celles au-delà de sa capacité
|
||||
* libres ; l'état des places n'est pas touché, et rien ne s'écrit (§ 7.1).
|
||||
*
|
||||
* L'option impression rend le plan imprimé (§ 11.7) : k n'y entre pas,
|
||||
* chaque table est en régime sièges, ses chaises de 1 à sa capacité, et les
|
||||
* listes se rendent dès qu'elles sont demandées. Le papier ne reçoit aucun
|
||||
* geste : poignee, selection, actif et miseEnEvidence sont sans effet, et
|
||||
* aucun porteur n'est marqué. Le modèle porte impression, que le dessin lit
|
||||
* pour ne poser ni poignée, ni cible, ni identifiant, ni rôle.
|
||||
*
|
||||
* @param {Object} etatPlaces EtatPlaces (A5)
|
||||
* @param {{k: number, listes: boolean, miseEnEvidence: number|null,
|
||||
* @param {{k?: number, listes: boolean, miseEnEvidence?: number|null,
|
||||
* nomDe: (id: number) => string, diagnostic?: Object|null,
|
||||
* poignee?: {table: number, places: number}|null,
|
||||
* selection?: {table: number, siege?: number|null}|null,
|
||||
* actif?: {table: number, siege: number|null}|null}} options
|
||||
* diagnostic : celui que rend diagnostiquer, compléments de M1 compris
|
||||
* actif?: {table: number, siege: number|null}|null,
|
||||
* impression?: boolean}} options
|
||||
* diagnostic : celui que rend diagnostiquer, compléments de M1 compris ;
|
||||
* k : exigé hors de l'impression ; miseEnEvidence : null par défaut
|
||||
* @returns {Object} ModeleDessin
|
||||
*/
|
||||
export function modeleDessin(
|
||||
etatPlaces,
|
||||
{ k, listes, miseEnEvidence, nomDe, diagnostic = null, poignee = null, selection = null, actif = null },
|
||||
{ k, listes, miseEnEvidence = null, nomDe, diagnostic = null, poignee = null, selection = null, actif = null, impression = false },
|
||||
) {
|
||||
const gestes = impression ? { miseEnEvidence: null, selection: null, actif: null } : { miseEnEvidence, selection, actif };
|
||||
const tables = etatPlaces.tables.map((etatTable) =>
|
||||
tableDessin(
|
||||
etatTable,
|
||||
{ k, miseEnEvidence, nomDe, selection, actif },
|
||||
poignee !== null && poignee.table === etatTable.id ? poignee.places : etatTable.capacite,
|
||||
{ k, nomDe, impression, ...gestes },
|
||||
!impression && poignee !== null && poignee.table === etatTable.id ? poignee.places : etatTable.capacite,
|
||||
),
|
||||
);
|
||||
const toutesEnConflit = tables.length > 0 && tables.every(({ conflit }) => conflit);
|
||||
|
|
@ -415,7 +426,8 @@ export function modeleDessin(
|
|||
tables,
|
||||
signalerConflits: !toutesEnConflit,
|
||||
bandeau: bandeauDe(toutesEnConflit, diagnostic),
|
||||
listesRendues: listes && k >= constante('K_LISTES'),
|
||||
listesRendues: listes && (impression || k >= constante('K_LISTES')),
|
||||
impression,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1217,3 +1217,92 @@ describe('les clés que les panneaux composent existent (§ 14.6)', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('modeleDessin, option impression : le plan imprimé (§ 11.7, § 13.5)', () => {
|
||||
// La grande démonstration au tour 1, sa proposition montrée : des tables
|
||||
// de toutes tailles, dont le régime à l'écran dépend de k.
|
||||
const grande = () => analyser(texteLivre('grande')).charge;
|
||||
|
||||
test('régime des sièges pour chaque table, quel que soit k ; chaque chaise dessinée, de 1 à la capacité', () => {
|
||||
const charge = grande();
|
||||
const etat = etatPlaces(charge, { tour: 1 });
|
||||
// À l'écran, au plus petit zoom, aucune chaise ne se dessine.
|
||||
const ecran = modeleDessin(etat, options(charge, { k: 0.01 }));
|
||||
assert.ok(ecran.tables.every(({ regime }) => regime === 'table'));
|
||||
for (const k of [undefined, 0.01, 5]) {
|
||||
const modele = modeleDessin(etat, options(charge, { k, impression: true }));
|
||||
assert.equal(modele.tables.length, etat.tables.length);
|
||||
for (const [rang, table] of modele.tables.entries()) {
|
||||
assert.equal(table.regime, 'sieges', `table ${table.id}, k = ${String(k)}`);
|
||||
assert.deepEqual(
|
||||
table.chaises.map(({ siege }) => siege),
|
||||
etat.tables[rang].chaises.map(({ siege }) => siege),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('ni sélection, ni focus, ni mise en évidence : les options de l’écran sont sans effet', () => {
|
||||
const charge = avecPropositions(petite());
|
||||
const etat = etatPlaces(charge, { tour: 1, proposition: charge.propositions[0].id });
|
||||
const [premiere, deuxieme] = etat.tables;
|
||||
const occupant = premiere.chaises.find((chaise) => chaise.occupant !== null).occupant;
|
||||
const ecran = {
|
||||
selection: { table: premiere.id, siege: 1 },
|
||||
actif: { table: deuxieme.id, siege: null },
|
||||
miseEnEvidence: occupant,
|
||||
poignee: { table: premiere.id, places: 1 },
|
||||
};
|
||||
// À l'écran, ces options marquent leur porteur.
|
||||
const marque = modeleDessin(etat, options(charge, { k: 5, ...ecran }));
|
||||
assert.ok(marque.tables.some((table) => table.focus || table.chaises.some((chaise) => chaise.selection || chaise.miseEnEvidence)));
|
||||
const modele = modeleDessin(etat, options(charge, { impression: true, ...ecran }));
|
||||
for (const table of modele.tables) {
|
||||
assert.equal(table.selection, false);
|
||||
assert.equal(table.focus, false);
|
||||
for (const chaise of table.chaises) {
|
||||
assert.deepEqual([chaise.selection, chaise.focus, chaise.miseEnEvidence], [false, false, false], `table ${table.id}`);
|
||||
}
|
||||
}
|
||||
// La poignée ne retire aucune chaise au papier : la table tenue garde sa capacité.
|
||||
assert.deepEqual(
|
||||
modele.tables[0].chaises.map(({ siege }) => siege),
|
||||
Array.from({ length: premiere.capacite }, (_, rang) => rang + 1),
|
||||
);
|
||||
});
|
||||
|
||||
test('les listes se rendent quand elles sont demandées, sans égard à K_LISTES', () => {
|
||||
const charge = avecPropositions(petite());
|
||||
const etat = etatPlaces(charge, { tour: 1 });
|
||||
assert.equal(modeleDessin(etat, options(charge, { k: K_LISTES - 0.01, impression: true })).listesRendues, true);
|
||||
assert.equal(modeleDessin(etat, options(charge, { impression: true })).listesRendues, true);
|
||||
assert.equal(modeleDessin(etat, options(charge, { impression: true, listes: false })).listesRendues, false);
|
||||
});
|
||||
|
||||
test('le modèle se dit imprimé, et l’écran ne l’est pas : le dessin y lit l’absence de poignée, d’identifiant et de cible', () => {
|
||||
const charge = avecPropositions(petite());
|
||||
const etat = etatPlaces(charge, { tour: 1 });
|
||||
assert.equal(modeleDessin(etat, options(charge, { impression: true })).impression, true);
|
||||
assert.equal(modeleDessin(etat, options(charge)).impression, false);
|
||||
});
|
||||
|
||||
test('les conflits, les pastilles, les marques et les lignes sont ceux de l’écran', () => {
|
||||
const charge = avecPropositions(petite('petite-conflit'));
|
||||
const etat = etatPlaces(charge, { tour: 1, proposition: charge.propositions[0].id });
|
||||
const ecran = modeleDessin(etat, options(charge, { k: 5 }));
|
||||
const imprime = modeleDessin(etat, options(charge, { impression: true }));
|
||||
assert.ok(imprime.tables.some(({ conflit }) => conflit), 'une table en conflit au moins');
|
||||
assert.equal(imprime.signalerConflits, ecran.signalerConflits);
|
||||
for (const [rang, table] of imprime.tables.entries()) {
|
||||
const voisine = ecran.tables[rang];
|
||||
assert.deepEqual(
|
||||
[table.conflit, table.pastille, table.trames, table.lignes, table.enTete],
|
||||
[voisine.conflit, voisine.pastille, voisine.trames, voisine.lignes, voisine.enTete],
|
||||
);
|
||||
assert.deepEqual(
|
||||
table.chaises.map(({ remplissage, marques }) => [remplissage, marques]),
|
||||
voisine.chaises.map(({ remplissage, marques }) => [remplissage, marques]),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -723,6 +723,26 @@ describe('les gardes relèvent la faute qu’un fragment porte (§ 14.2)', () =>
|
|||
expect(noye.marques.fautes[0]).toContain('1.00:1');
|
||||
});
|
||||
|
||||
test('marques : sous le document imprimé, une marque se mesure au jeton du papier — celui du clair, dans les deux thèmes —, non à celui de la page', () => {
|
||||
const racine = document.documentElement;
|
||||
const theme = racine.getAttribute('data-theme');
|
||||
racine.setAttribute('data-theme', 'sombre');
|
||||
try {
|
||||
const { vues, fautes, mesures } = avecFragment(
|
||||
'<div data-impression style="background:var(--couleur-papier);padding:8px"><svg width="60" height="40">' +
|
||||
'<circle data-marque="contourTable" cx="20" cy="20" r="12" style="fill:var(--couleur-papier);stroke:var(--couleur-table-trait);stroke-width:3px" />' +
|
||||
'</svg></div>',
|
||||
(c) => releverMarques(c),
|
||||
);
|
||||
expect(vues).toEqual(['contourTable']);
|
||||
expect(fautes).toEqual([]);
|
||||
expect(mesures).toBe(1);
|
||||
} finally {
|
||||
if (theme === null) racine.removeAttribute('data-theme');
|
||||
else racine.setAttribute('data-theme', theme);
|
||||
}
|
||||
});
|
||||
|
||||
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>' +
|
||||
|
|
|
|||
|
|
@ -231,11 +231,13 @@ export function releverTextes(racine, { fenetre = false } = {}) {
|
|||
|
||||
// --- Les marques -------------------------------------------------------------------
|
||||
|
||||
/** La couleur résolue d'un jeton dans le thème courant. */
|
||||
export function couleurDeJeton(jeton) {
|
||||
/** La couleur résolue d'un jeton dans le thème courant, ou sous hote : un
|
||||
* élément qui redéclare les jetons — le document imprimé de l'aperçu,
|
||||
* [data-impression], porte ceux du clair dans les deux thèmes (§ 11.7). */
|
||||
export function couleurDeJeton(jeton, hote = document.body) {
|
||||
const sonde = document.createElement('span');
|
||||
sonde.style.color = `var(${jeton})`;
|
||||
document.body.append(sonde);
|
||||
hote.append(sonde);
|
||||
try {
|
||||
return composantes(getComputedStyle(sonde).color);
|
||||
} finally {
|
||||
|
|
@ -363,7 +365,9 @@ function voisinage(element, jeton) {
|
|||
/**
|
||||
* Les marques rendues sous racine (§ 14.5, § 19.5) : chacune nommée au
|
||||
* registre, peinte de son jeton, à 3:1 de ce qui l'entoure, sans
|
||||
* atténuation. → { vues: string[], fautes, attenuations, mesures, ratios },
|
||||
* atténuation. Sous le document imprimé de l'aperçu, le jeton se résout
|
||||
* comme le papier le résout : au clair, dans les deux thèmes.
|
||||
* → { vues: string[], fautes, attenuations, mesures, ratios },
|
||||
* ratios : { marque, jeton, ratio } de chaque marque mesurée, contre ce qui
|
||||
* l'entoure de plus défavorable, dans l'ordre des mesures. fenetre vrai :
|
||||
* seules les marques que la fenêtre montre entières se lisent, sans rien
|
||||
|
|
@ -397,7 +401,7 @@ export function releverMarques(racine, { fenetre = false } = {}) {
|
|||
if (modale !== null && !(element instanceof SVGElement) && !modale.contains(element)) continue;
|
||||
vues.add(nom);
|
||||
if (!fenetre) amener(element);
|
||||
const jeton = couleurDeJeton(entree.jeton);
|
||||
const jeton = couleurDeJeton(entree.jeton, element.closest('[data-impression]') ?? document.body);
|
||||
try {
|
||||
const { peinture, voisins } = voisinage(element, jeton);
|
||||
if (peinture === null) {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const ENTREES = [
|
|||
['data-bandeau', 'le bandeau d’application', ['']],
|
||||
['data-bandeau-plan', 'plan : le bandeau au-dessus de la surface', ['']],
|
||||
['data-bloc', 'à propos : le bloc de version à copier', ['']],
|
||||
['data-cartouche', 'aperçu : le cartouche de repérage d’une page du plan imprimé', ['']],
|
||||
['data-cellule', 'import : une cellule de l’aperçu, par son champ', null],
|
||||
['data-champ', 'un champ de saisie ou un sélecteur, par son nom', null],
|
||||
['data-chargement', 'accueil : le signe d’un calcul en cours', ['']],
|
||||
|
|
@ -105,6 +106,7 @@ const ENTREES = [
|
|||
['data-meilleure-id', 'l’identifiant de la meilleure proposition', ['']],
|
||||
['data-meilleure-libelle-id', 'génération : le libellé de l’identifiant de la meilleure', ['']],
|
||||
['data-meilleure-vide', 'génération : la meilleure absente', ['']],
|
||||
['data-mention', 'aperçu : une mention d’une page du plan imprimé — disposition relative, listes hors d’échelle', ['listes', 'relative']],
|
||||
['data-menu', 'plan : le menu d’une personne', null],
|
||||
['data-mode', 'événement : le cadre de mode, par sa forme', null],
|
||||
['data-mode-libelle', 'le libellé de mode', ['bandeau', 'cadre']],
|
||||
|
|
|
|||
Loading…
Reference in a new issue