diff --git a/README.md b/README.md index ab412e5..44926b4 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ l'exécution elle-même —, son statut et la façon de le relever. Une valeur a | --- | --- | --- | --- | --- | --- | --- | --- | | `SEUIL_GLISSER_PX` | déplacement du pointeur, entre pointerdown et pointerup, au-delà duquel un appui devient un glissement | 4 px | défaut de travail | manuel | vingt clics délibérés au pavé tactile de la machine de référence ; le plus grand déplacement entre pointerdown et pointerup, × 1,5, écrit dans ce registre | — | pavé tactile de la machine de référence | | `DUREE_PRESSION_LONGUE_MS` | durée d'un appui immobile au-delà de laquelle il ouvre le menu d'une personne | 500 ms | défaut de travail | manuel | à l'écran tactile de la machine de référence, la plus courte durée qu'aucun appui bref ne déclenche, sur dix essais, écrite dans ce registre | — | écran tactile de la machine de référence | -| `SEUIL_SIEGES_LISIBLES` | plus grand nombre de places d'une table dont la liste de noms reste lisible | 16 places | défaut de travail | banc | banc de mesure, cadre de 1 366 × 700 px : le plus grand nombre de places dont la liste, à la hauteur de ligne mesurée, tient dans la hauteur du cadre au zoom ajusté d'une table seule ; écrit dans ce registre | 18 places | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur non ralenti, police Noto Sans | +| `SEUIL_SIEGES_LISIBLES` | plus grand nombre de places d'une table dont la liste de noms reste lisible | 18 places | mesurée | banc | banc de mesure, cadre de 1 366 × 700 px : le plus grand nombre de places dont la liste, à la hauteur de ligne mesurée, tient dans la hauteur du cadre au zoom ajusté d'une table seule, ronde puis carrée, par dichotomie ; le plus petit des deux seuils, écrit dans ce registre | 18 places | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur non ralenti, police Noto Sans | | `K_LISTES` | échelle sous laquelle le plan retire les listes de noms | 0,3 px/cm | défaut de travail | manuel | une personne qui n'a pas écrit le logiciel lit les listes de la grande démonstration sur la machine de référence à k décroissant ; le plus petit k où elle les lit sans erreur, écrit dans ce registre | — | grande démonstration, 33 tables avec listes, machine de référence, cadre de 1 366 × 700 px | | `LARGEUR_BLOC_NOMS_CM` | largeur, dans le dessin, du bloc de noms d'une table, où un nom trop long s'abrège | 500 cm | défaut de travail | manuel | relecture de la grande démonstration au zoom par défaut par une personne qui n'a pas écrit le logiciel (§ 19.11) ; la largeur retenue, écrite dans ce registre | — | grande démonstration au zoom par défaut | | `HAUTEUR_LIGNE` | hauteur d'une ligne d'une liste de noms, dans le dessin | relevée à l’exécution | mesurée | exécution | mesurée à chaque montage du plan : measureText du gabarit « Ôjgq Æ W » sur la police calculée des listes ; aucune valeur n'est écrite dans ce registre | — | police calculée des listes, relevée à chaque montage du plan | @@ -135,7 +135,7 @@ l'exécution elle-même —, son statut et la façon de le relever. Une valeur a | `CORPS_MIN_IMPRESSION_MM` | hauteur de caractère du plan imprimé en deçà de laquelle le plan se découpe en pages | 2,5 mm | défaut de travail | manuel | page d'essai à corps décroissants imprimée sur l'imprimante visée, lue à bout de bras ; le plus petit corps lu sans erreur, écrit dans ce registre | — | imprimante visée, chaque papier des sorties, lecture à bout de bras | | `MARGE_PAGE_MM` | marge de chaque bord d'une page imprimée par l'aperçu | 10 mm | défaut de travail | manuel | marge non imprimable mesurée sur chaque imprimante visée (une page de filets à 1 mm d'écart, lue à la règle) ; la plus grande, écrite dans ce registre | — | imprimantes visées, chaque papier des sorties | | `HAUTEUR_BANDE_IMPRESSION` | hauteur de ce que porte une feuille imprimée hors de sa zone de contenu : pied, mentions, barre, cartouche | relevée à l’exécution | mesurée | exécution | mesurée à l'ouverture de l'aperçu : tout ce qu'une feuille porte hors de [data-contenu] ; aucune valeur n'est écrite dans ce registre | — | rendu de l'aperçu, relevé à chaque ouverture | -| `TOLERANCE_CHAINE_TEMOIN` | écart relatif admis entre la longueur rendue de la chaîne témoin et celle que donnent les avances de la police | 0,01 rapport | défaut de travail | banc | banc de mesure : le quart de l'écart relatif entre la police embarquée et une famille de repli forcée, et au moins trois fois la gigue de dix mesures ; écrit dans ce registre | 0,011 rapport | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur non ralenti, police Noto Sans | +| `TOLERANCE_CHAINE_TEMOIN` | écart relatif admis entre la longueur rendue de la chaîne témoin et celle que donnent les avances de la police | 0,011 rapport | mesurée | banc | banc de mesure : le quart de l'écart relatif entre la police embarquée et une famille de repli forcée, et au moins trois fois la gigue de dix mesures, chaîne témoin à 16 px, crénage et ligatures coupés ; arrondi par excès à deux chiffres significatifs, écrit dans ce registre | 0,011 rapport | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur non ralenti, police Noto Sans | | `RALENTISSEMENT_PLEINE_CHARGE` | ralentissement d'une génération quand tous les processeurs logiques travaillent, t(L) / t(1) | 2 × | défaut de travail | banc | banc de mesure : durée d'une même génération sur un travailleur, puis sur L travailleurs ; le rapport ρ des médianes, écrit dans ce registre | 2,79 × | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur non ralenti, police Noto Sans | diff --git a/scripts/banc/lancer.test.js b/scripts/banc/lancer.test.js index 47cd4eb..bff2de3 100644 --- a/scripts/banc/lancer.test.js +++ b/scripts/banc/lancer.test.js @@ -13,7 +13,7 @@ import assert from 'node:assert/strict'; import { describe, test } from '../../test/lanceur.js'; import { CONSTANTES } from '../../src/geometrie/constantes.js'; import { projetBanc } from './projet.js'; -import { verifierReleve } from './releve.js'; +import { accordRegistre, verifierReleve } from './releve.js'; import releveCommite from './releve.json' with { type: 'json' }; import { ATTENDUES, @@ -312,13 +312,22 @@ describe('lancer : executer, sur des effets factices', () => { assert.deepEqual(noms(releve), [...ATTENDUES]); const texte = p.lignes.join('\n'); assert.ok(texte.includes(regionConstantes({ releve }))); - assert.match(texte, /SEUIL_SIEGES_LISIBLES : registre 16 places, relevé 17 places — à adopter/); + // Les valeurs du registre se lisent au registre : une adoption ne change + // pas ce que l'épreuve éprouve. + const registre = (nom) => CONSTANTES.find((c) => c.nom === nom).valeur; + assert.ok(texte.includes(`SEUIL_SIEGES_LISIBLES : registre ${registre('SEUIL_SIEGES_LISIBLES')} places, relevé 17 places — à adopter`)); assert.match( texte, /RALENTISSEMENT_PLEINE_CHARGE : registre 2 ×, relevé 2,5 × — relevé, non adopté : défaut de travail sous la machine de référence/, ); assert.doesNotMatch(texte, /RALENTISSEMENT_PLEINE_CHARGE : .*à adopter/); - assert.match(texte, /Accord du registre avec le relevé : aucun écart/); + // L'accord imprimé est celui du registre avec le relevé écrit : une + // constante adoptée que ce relevé inventé contredit y est nommée. + const ecarts = accordRegistre(CONSTANTES, releve); + const accord = ecarts.length === 0 + ? 'Accord du registre avec le relevé : aucun écart.' + : ['Accord du registre avec le relevé :', ...ecarts.map(({ nom, faute }) => `- ${nom} : ${faute}`)].join('\n'); + assert.ok(texte.includes(accord), accord); }); test('une épreuve du banc tombée (un contrôle du harnais) : code 1, aucun relevé écrit', async () => { diff --git a/src/geometrie/constantes.js b/src/geometrie/constantes.js index 3761b0d..8b5f9cf 100644 --- a/src/geometrie/constantes.js +++ b/src/geometrie/constantes.js @@ -57,16 +57,19 @@ export const CONSTANTES = Object.freeze( }, { nom: 'SEUIL_SIEGES_LISIBLES', - valeur: 16, + valeur: 18, unite: 'places', mesure: "plus grand nombre de places d'une table dont la liste de noms reste lisible", raison: "au-delà, le dessin d'une table cesse d'être lisible, et l'application le signale (§ 6.2)", - configuration: 'banc de mesure, cadre de 1 366 × 700 px', + configuration: + 'banc de mesure, cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, ' + + '16 processeurs logiques, processeur non ralenti, police Noto Sans', proprietaire: 'banc', - statut: 'defautDeTravail', + statut: 'mesuree', protocole: 'banc de mesure, cadre de 1 366 × 700 px : le plus grand nombre de places dont la liste, à la hauteur ' + - "de ligne mesurée, tient dans la hauteur du cadre au zoom ajusté d'une table seule ; écrit dans ce registre", + "de ligne mesurée, tient dans la hauteur du cadre au zoom ajusté d'une table seule, ronde puis carrée, " + + 'par dichotomie ; le plus petit des deux seuils, écrit dans ce registre', }, { nom: 'K_LISTES', @@ -251,18 +254,21 @@ export const CONSTANTES = Object.freeze( }, { nom: 'TOLERANCE_CHAINE_TEMOIN', - valeur: 0.01, + valeur: 0.011, unite: 'rapport', mesure: 'écart relatif admis entre la longueur rendue de la chaîne témoin et celle que donnent les avances de la police', raison: "la mesure du § 19.4 trahit une police de repli ; trop serrée, elle échoue sur la gigue du rendu, trop " + 'large, elle laisse passer le repli', - configuration: 'banc de mesure, Chromium du projet, cadre de 1 366 × 700 px, facteur 1', + configuration: + 'banc de mesure, cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, ' + + '16 processeurs logiques, processeur non ralenti, police Noto Sans', proprietaire: 'banc', - statut: 'defautDeTravail', + statut: 'mesuree', protocole: "banc de mesure : le quart de l'écart relatif entre la police embarquée et une famille de repli " + - 'forcée, et au moins trois fois la gigue de dix mesures ; écrit dans ce registre', + 'forcée, et au moins trois fois la gigue de dix mesures, chaîne témoin à 16 px, crénage et ligatures ' + + 'coupés ; arrondi par excès à deux chiffres significatifs, écrit dans ce registre', }, { nom: 'RALENTISSEMENT_PLEINE_CHARGE', diff --git a/src/geometrie/constantes.test.js b/src/geometrie/constantes.test.js index da244cc..24a94b9 100644 --- a/src/geometrie/constantes.test.js +++ b/src/geometrie/constantes.test.js @@ -7,6 +7,10 @@ // l'accord du registre avec le relevé committé du banc : le banc écrit un // relevé, jamais le registre, et une constante du banc déclarée mesurée // porte la valeur que le relevé propose, sur la configuration qu'il décrit. +// Deux valeurs du relevé sont adoptées — le seuil de sièges lisibles et la +// tolérance de la chaîne témoin — ; le ralentissement à pleine charge garde +// son défaut de travail : la machine de référence est le poste de +// développement, ρ à son défaut (Q13 b). import assert from 'node:assert/strict'; import { describe, test } from '../../test/lanceur.js'; import { accordRegistre } from '../../scripts/banc/releve.js'; @@ -18,7 +22,7 @@ import { CONSTANTES, constante, verifierRegistre } from './constantes.js'; const CONTRAT = [ ['SEUIL_GLISSER_PX', 4, 'px', 'manuel', 'defautDeTravail'], ['DUREE_PRESSION_LONGUE_MS', 500, 'ms', 'manuel', 'defautDeTravail'], - ['SEUIL_SIEGES_LISIBLES', 16, 'places', 'banc', 'defautDeTravail'], + ['SEUIL_SIEGES_LISIBLES', 18, 'places', 'banc', 'mesuree'], ['K_LISTES', 0.3, 'px/cm', 'manuel', 'defautDeTravail'], ['LARGEUR_BLOC_NOMS_CM', 500, 'cm', 'manuel', 'defautDeTravail'], ['HAUTEUR_LIGNE', null, 'cm', 'execution', 'mesuree'], @@ -32,10 +36,13 @@ const CONTRAT = [ ['CORPS_MIN_IMPRESSION_MM', 2.5, 'mm', 'manuel', 'defautDeTravail'], ['MARGE_PAGE_MM', 10, 'mm', 'manuel', 'defautDeTravail'], ['HAUTEUR_BANDE_IMPRESSION', null, 'mm', 'execution', 'mesuree'], - ['TOLERANCE_CHAINE_TEMOIN', 0.01, 'rapport', 'banc', 'defautDeTravail'], + ['TOLERANCE_CHAINE_TEMOIN', 0.011, 'rapport', 'banc', 'mesuree'], ['RALENTISSEMENT_PLEINE_CHARGE', 2, '×', 'banc', 'defautDeTravail'], ]; +// Les constantes du banc dont le registre a adopté la valeur relevée. +const ADOPTEES = ['SEUIL_SIEGES_LISIBLES', 'TOLERANCE_CHAINE_TEMOIN']; + // Une copie du registre, entrée par entrée, que l'épreuve peut altérer. const copie = () => CONSTANTES.map((entree) => ({ ...entree })); @@ -52,9 +59,10 @@ describe('constantes : le registre (§ 14.1, § 19.10)', () => { } }); - test('tant que son propriétaire ne l\'a pas relevée, une constante se déclare défaut de travail ; une constante relevée à l\'exécution est mesurée', () => { + test('tant que son propriétaire ne l\'a pas relevée, une constante se déclare défaut de travail ; une constante relevée à l\'exécution, ou adoptée du relevé, est mesurée', () => { for (const { nom, proprietaire, statut } of CONSTANTES) { - assert.equal(statut, proprietaire === 'execution' ? 'mesuree' : 'defautDeTravail', nom); + const mesuree = proprietaire === 'execution' || ADOPTEES.includes(nom); + assert.equal(statut, mesuree ? 'mesuree' : 'defautDeTravail', nom); } }); @@ -137,6 +145,32 @@ describe('constantes : le registre (§ 14.1, § 19.10)', () => { describe('constantes : accord avec le relevé du banc (§ 19.10, § 14.1)', () => { test('chaque constante du banc déclarée mesurée porte la valeur du relevé committé et cite sa configuration', () => { assert.ok(CONSTANTES.some((c) => c.proprietaire === 'banc'), 'le registre ne porte aucune constante du banc'); + // L'accord n'examine que les constantes mesurées : sans elles, il serait + // vert sans rien éprouver (§ 14.2). + assert.deepEqual( + CONSTANTES.filter((c) => c.proprietaire === 'banc' && c.statut === 'mesuree').map(({ nom }) => nom), + ADOPTEES, + ); assert.deepEqual(accordRegistre(CONSTANTES, releveCommite), []); }); + + test('une adoption porte la valeur proposée par le relevé, et sa configuration cite le cadre, le navigateur et la police du relevé', () => { + for (const nom of ADOPTEES) { + const entree = CONSTANTES.find((c) => c.nom === nom); + const mesure = releveCommite.mesures.find((m) => m.nom === nom); + assert.equal(entree.valeur, mesure.valeurProposee, nom); + assert.ok(entree.configuration.includes(releveCommite.configuration.navigateur), nom); + assert.ok(entree.configuration.includes(`police ${mesure.police}`), nom); + assert.match(entree.configuration, /cadre de 1 366 × 700 px, facteur 1/, nom); + } + // Une valeur du registre qui s'écarte du relevé est une faute de l'accord. + const ecartee = CONSTANTES.map((c) => (c.nom === 'SEUIL_SIEGES_LISIBLES' ? { ...c, valeur: 16 } : c)); + assert.deepEqual(accordRegistre(ecartee, releveCommite), [{ nom: 'SEUIL_SIEGES_LISIBLES', faute: 'valeur' }]); + }); + + test('le ralentissement à pleine charge garde sa valeur de départ, défaut de travail, quoi que le relevé propose (Q13 b)', () => { + const entree = CONSTANTES.find((c) => c.nom === 'RALENTISSEMENT_PLEINE_CHARGE'); + assert.deepEqual([entree.valeur, entree.statut], [2, 'defautDeTravail']); + assert.notEqual(releveCommite.mesures.find((m) => m.nom === 'RALENTISSEMENT_PLEINE_CHARGE').valeurProposee, entree.valeur); + }); }); diff --git a/src/interface/impression/FeuillesTables.svelte b/src/interface/impression/FeuillesTables.svelte index 826f4d4..5b59392 100644 --- a/src/interface/impression/FeuillesTables.svelte +++ b/src/interface/impression/FeuillesTables.svelte @@ -32,17 +32,29 @@ // (mesurerListes). Une ligne encore plus haute qu'une page — un nom fait // de nombreux mots courts — resserre les groupes de sa feuille // (resserrerGroupes), et la passe se refait ; sinon paginerFeuilles en - // fait les pages. Une ligne qui passe la page sous un seul tour par - // groupe, sa colonne à toute la largeur que la place laisse, reste seule - // sur sa page. + // fait les pages. Une ligne qui passe encore la page sous un seul tour + // par groupe, sa colonne à toute la largeur que la place laisse — un nom + // de milliers de caractères —, ne se coupe pas au bord de la page : la + // même passe en essaie les morceaux sur la ligne rendue (couperLigne), et + // la ligne se continue, un morceau par page, sous l'en-tête répété et le + // numéro de sa place (morcelerPages, trancher). // // La source est celle de l'aperçu, tour par tour (etatsDesTours). // // Sans bandeMm, la vue rend sa feuille gabarit, que l'aperçu mesure. import { getContext, tick } from 'svelte'; import Feuille from './Feuille.svelte'; - import { etatsDesTours, feuilleDuTour, feuillesTables, grouperTours, largeursColonnes, resserrerGroupes, toursParGroupe } from './listes.js'; - import { mesurerListes, mesurerPlace, paginerFeuilles } from './pagination.js'; + import { + etatsDesTours, + feuilleDuTour, + feuillesTables, + grouperTours, + largeursColonnes, + resserrerGroupes, + textesCellule, + toursParGroupe, + } from './listes.js'; + import { couperLigne, mesurerListes, mesurerPlace, morcelerPages, paginerFeuilles, trancher } from './pagination.js'; let { etat, options, feuille, bandeMm = null, orientation = $bindable(null) } = $props(); @@ -63,7 +75,9 @@ const groupes = $derived(parGroupe === null ? null : grouperTours(listes, parGroupe)); const cleGroupes = $derived(JSON.stringify([cle, parGroupe])); let mesures = $state(null); - const pages = $derived(mesures !== null && mesures.cle === cleGroupes ? paginerFeuilles(mesures.feuilles, mesures.utile) : null); + const pages = $derived( + mesures !== null && mesures.cle === cleGroupes ? morcelerPages(paginerFeuilles(mesures.feuilles, mesures.utile), mesures.coupures) : null, + ); const feuilleMesure = $derived({ ...feuille, filigrane: false }); // Les sondes de la première passe, une par feuille. let sondes = $state([]); @@ -71,8 +85,9 @@ // Mesure la feuille de mesure une fois le rendu à jour (tick) : la clé // lue alors est celle des valeurs que le DOM porte. Sans tailles à jour, // lit les sondes ; avec elles, les hauteurs des groupes, qui donnent les - // pages ou des tailles resserrées. La lecture des clés relance la mesure - // quand l'une change avant que les pages viennent. + // pages ou des tailles resserrées, et les morceaux de chaque ligne encore + // seule. La lecture des clés relance la mesure quand l'une change avant + // que les pages viennent. function mesurer(conteneur) { const voulue = cleGroupes; const valeurs = parGroupe; @@ -87,9 +102,20 @@ return; } const lues = mesurerListes(contenu); - const resserrees = resserrerGroupes(valeurs, groupesLus, paginerFeuilles(lues.feuilles, lues.utile)); - if (resserrees === null) mesures = { cle: voulue, ...lues }; - else tailles = { cle, valeurs: resserrees }; + const paginees = paginerFeuilles(lues.feuilles, lues.utile); + const resserrees = resserrerGroupes(valeurs, groupesLus, paginees); + if (resserrees !== null) { + tailles = { cle, valeurs: resserrees }; + return; + } + const listesRendues = contenu.querySelectorAll('[data-liste-imprimee]'); + const coupures = []; + for (const { feuille: rang, debut, seule } of paginees) { + if (!seule) continue; + const ligne = listesRendues[rang].querySelector('tbody').rows[debut]; + coupures.push({ feuille: rang, ligne: debut, bornes: couperLigne(ligne, lues.utile - lues.feuilles[rang].entete) }); + } + mesures = { cle: voulue, ...lues, coupures }; }); return () => { active = false; @@ -107,18 +133,25 @@ }); -{#snippet celluleTour(cellule)} + +{#snippet celluleTour(cellule, parts = null)} + {@const [nom, titre, nonPourvu] = parts ?? textesCellule(cellule, t)} - {#if cellule.nom === null} - {t('feuilles.tables.vide')} - {:else} - {cellule.nom} + {#if nom !== ''} + {#if cellule.nom === null} + {nom} + {:else} + {nom} + {/if} {/if} - {#if cellule.titre !== null} + {#if titre !== '' || nonPourvu !== ''} - {cellule.titre.libelle} - {#if !cellule.titre.pourvu} - {t('feuilles.tables.titreNonPourvu')} + {#if titre !== ''} + {titre} + {/if} + {#if nonPourvu !== ''} + {nonPourvu} {/if} {/if} @@ -142,7 +175,7 @@ {/snippet} -{#snippet blocTable(liste, rangListe, debut, fin)} +{#snippet blocTable(liste, rangListe, debut, fin, morceau = null)}

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

@@ -161,10 +194,14 @@ {#each liste.lignes.slice(debut, fin) as ligne, rang (ligne.place)} + + {@const parts = morceau === null ? null : trancher(ligne.cellules.flatMap((cellule) => textesCellule(cellule, t)), morceau.debut, morceau.fin)} - {#each ligne.cellules as cellule (cellule.tour)} - {@render celluleTour(cellule)} + {#each ligne.cellules as cellule, rangCellule (cellule.tour)} + {@render celluleTour(cellule, parts === null ? null : parts.slice(3 * rangCellule, 3 * rangCellule + 3))} {/each} {/each} @@ -201,7 +238,7 @@ {#each pages as page (page.rang)} {#snippet contenu()} - {@render blocTable(groupes[page.feuille], page.feuille, page.debut, page.fin)} + {@render blocTable(groupes[page.feuille], page.feuille, page.debut, page.fin, page.morceau)} {/snippet} {/each} diff --git a/src/interface/impression/ListeAccueil.svelte b/src/interface/impression/ListeAccueil.svelte index 52403c3..18f6443 100644 --- a/src/interface/impression/ListeAccueil.svelte +++ b/src/interface/impression/ListeAccueil.svelte @@ -19,6 +19,14 @@ // les lignes changent. Les colonnes ont des largeurs fixées // (largeursColonnes) : la mesure et les pages replient de même. // + // Une ligne plus haute qu'une page — un nom de milliers de caractères — + // ne se coupe pas au bord de la page : la même passe en essaie les + // morceaux sur la ligne rendue (couperLigne), et la ligne se continue, un + // morceau par page, sous l'en-tête répété. Ses textes se suivent dans + // l'ordre de ses cellules — nom, prénom, appartenance, places + // (textesLigneAccueil) — et chaque page pose de chaque cellule la part qui + // tombe dans son morceau (morcelerPages, trancher). + // // Les colonnes de tour se règlent sur leur texte. La feuille de mesure // porte une sonde : un tableau d'une colonne, sans repli, qui réunit chaque // libellé de place et chaque tête de tour. Sa largeur, sur celle de la @@ -35,8 +43,8 @@ import { getContext, tick } from 'svelte'; import { cleNormalisee } from '../../application/csv.js'; import Feuille from './Feuille.svelte'; - import { etatsDesTours, largeursColonnes, libellePlace, lignesAccueil } from './listes.js'; - import { mesurerListes, mesurerPlace, paginerFeuilles } from './pagination.js'; + import { etatsDesTours, largeursColonnes, libellePlace, lignesAccueil, textesLigneAccueil } from './listes.js'; + import { couperLigne, mesurerListes, mesurerPlace, morcelerPages, paginerFeuilles, trancher } from './pagination.js'; let { etat, options, feuille, bandeMm = null, orientation = $bindable(null) } = $props(); @@ -68,14 +76,17 @@ let mesures = $state(null); const largeurs = $derived(largeursColonnes('accueil', tours.length, part !== null && part.cle === cle ? part.valeur : null)); let sonde = $state(null); - const pages = $derived(mesures !== null && mesures.cle === cle ? paginerFeuilles(mesures.feuilles, mesures.utile) : null); + const pages = $derived( + mesures !== null && mesures.cle === cle ? morcelerPages(paginerFeuilles(mesures.feuilles, mesures.utile), mesures.coupures) : null, + ); const feuilleMesure = $derived({ ...feuille, filigrane: false }); // Mesure la feuille de mesure une fois le rendu à jour (tick) : la // clé lue alors est celle des valeurs que le DOM porte. Sans part à jour, // lit la part d'un tour ; avec elle, les hauteurs de la liste rendue à ses - // largeurs. La lecture de cle et de part relance la mesure quand l'une - // change avant que les pages viennent. + // largeurs, et les morceaux de chaque ligne seule sur sa page. La lecture + // de cle et de part relance la mesure quand l'une change avant que les + // pages viennent. function mesurer(conteneur) { const voulue = cle; const partLue = part; @@ -86,7 +97,12 @@ if (partLue === null || partLue.cle !== cle) { part = { cle, valeur: mesurerPlace(sonde, contenu.querySelector('[data-liste-imprimee] table')) }; } else { - mesures = { cle, ...mesurerListes(contenu) }; + const lues = mesurerListes(contenu); + const corps = contenu.querySelector('[data-liste-imprimee] tbody'); + const coupures = paginerFeuilles(lues.feuilles, lues.utile) + .filter(({ seule }) => seule) + .map(({ feuille, debut }) => ({ feuille, ligne: debut, bornes: couperLigne(corps.rows[debut], lues.utile - lues.feuilles[feuille].entete) })); + mesures = { cle, ...lues, coupures }; } }); return () => { @@ -99,7 +115,10 @@ }); -{#snippet liste(debut, fin)} + +{#snippet liste(debut, fin, morceau = null)}

{t('feuilles.accueil.titre')}

{ligne.place}
@@ -120,17 +139,16 @@ {#each lignes.slice(debut, fin) as ligne, rang (ligne.participant)} + {@const textes = textesLigneAccueil(ligne, t)} + {@const [nom, prenom, appartenance, ...places] = morceau === null ? textes : trancher(textes, morceau.debut, morceau.fin)} - - - {#each ligne.places as place (place.tour)} - {@const libelle = libellePlace(place)} - + + + {#each places as place, rangPlace (rangPlace)} + {/each} {/each} @@ -163,7 +181,7 @@ {#each pages as page (page.rang)} {#snippet contenu()} - {@render liste(page.debut, page.fin)} + {@render liste(page.debut, page.fin, page.morceau)} {/snippet} {/each} diff --git a/src/interface/impression/Listes.navigateur.test.js b/src/interface/impression/Listes.navigateur.test.js index 1ea7b3d..11e9de4 100644 --- a/src/interface/impression/Listes.navigateur.test.js +++ b/src/interface/impression/Listes.navigateur.test.js @@ -16,7 +16,11 @@ // largeur de son plus long mot ; une soirée dont les tours n'y tiennent // pas, ou dont une ligne passerait la hauteur d'une page, se scinde en // groupes de tours, chaque groupe sous ses têtes de tour, chaque tour une -// fois par table, et aucune ligne ne déborde de sa page. +// fois par table, et aucune ligne ne déborde de sa page. Une ligne plus +// haute qu'une page, sur la feuille par table comme sur la liste d'accueil, +// se continue sur les feuilles suivantes : rien d'elle hors des zones de +// contenu, et ses parts mises bout à bout redonnent la ligne entière, chaque +// texte une fois. // // 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 @@ -27,6 +31,7 @@ // (§ 14.8). import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import { appliquerCommande } from '../../application/commandes.js'; +import { cleNormalisee } from '../../application/csv.js'; import { TABLES_APPLICATION } from '../../application/libelles.js'; import { nomAffiche } from '../../application/personnes.js'; import { estBrouillon } from '../../application/sorties.js'; @@ -36,6 +41,7 @@ import { analyser } from '../../stockage/document.js'; import { fixerCadre } from '../../../test/navigateur/entrees.js'; import { attendre, chargeBloquee, chargePrincipale, cliquer, creerContexte, ouvrirApercu } from '../../../test/navigateur/ecrans.js'; import { TABLES_INTERFACE } from '../libelles.js'; +import { etatsDesTours, feuillesTables, lignesAccueil, textesCellule, textesLigneAccueil } from './listes.js'; import { paginer } from './pagination.js'; const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); @@ -410,3 +416,122 @@ describe('la feuille par table : des tours nombreux sous un nom long (§ 7.4)', for (const [table, suite] of groupesParTable(m)) expect(suite, `table ${table}`).toEqual([attendues]); }); }); + +// Les lignes plus hautes qu'une page (§ 7.4) : un nom de milliers de +// caractères en mots courts, que même une colonne à toute la largeur que la +// place laisse ne tient pas sur une page. +const NOM_DE_PAGES = 'Ombrelle Pervenche '.repeat(1500).trim(); + +// La principale, la personne 1 renommée du nom de pages, à tours tours. +function chargeNomDePages(tours) { + return appliquerCommande( + 'changerTours', + appliquerCommande('modifierParticipant', chargePrincipale(), { id: 1, champs: { nom: NOM_DE_PAGES } }).charge, + { tours }, + ).charge; +} + +// Vérifie que rien d'une ligne ne tombe hors de la zone de contenu de sa +// feuille — ni sa boîte, ni aucun fragment de son texte —, et que chaque +// feuille porte un en-tête de liste. +function verifierDansLesZones(m) { + for (const feuille of feuilles(m)) { + const zone = boite(feuille.querySelector('[data-contenu]')); + expect(feuille.querySelectorAll('[data-entete-liste]'), 'un en-tête par feuille').toHaveLength(1); + for (const ligne of tous(feuille, '[data-ligne-imprimee]')) { + const plage = document.createRange(); + plage.selectNodeContents(ligne); + for (const rect of [boite(ligne), ...plage.getClientRects()]) { + const ou = `feuille ${feuille.dataset.feuille}, ligne ${ligne.dataset.ligneImprimee}`; + expect(rect.top, ou).toBeGreaterThanOrEqual(zone.top - JEU_PX); + expect(rect.bottom, ou).toBeLessThanOrEqual(zone.bottom + JEU_PX); + } + } + } +} + +// Ce que les cellules de données d'une ligne rendue posent : ses nœuds de +// texte non blancs, dans l'ordre du document, mis bout à bout. +function texteDesDonnees(ligne) { + return [...ligne.cells] + .filter((cellule) => cellule.tagName === 'TD') + .flatMap((cellule) => { + const lus = []; + const parcours = document.createTreeWalker(cellule, NodeFilter.SHOW_TEXT); + for (let noeud = parcours.nextNode(); noeud !== null; noeud = parcours.nextNode()) if (noeud.data.trim() !== '') lus.push(noeud.data); + return lus; + }) + .join(''); +} + +describe('la feuille par table : une ligne plus haute qu’une page sous un seul tour (§ 7.4)', () => { + test('deux tours, A4 : continuée sous l’en-tête répété, dans les zones ; le nom, puis le titre une fois', async () => { + const charge = chargeNomDePages(2); + const m = await ouvrirApercu(contexte, charge); + await choisirVue(m, 'tables'); + const attendu = plat(nomAffiche(charge.participants.find(({ id }) => id === 1))); + verifierDansLesZones(m); + + // La ligne du nom, à chaque tour : continuée sur plusieurs feuilles, ses + // morceaux mis bout à bout redonnent le nom entier, sans marque + // d'abréviation. + const morceaux = tous(m.racine, '[data-impression] [data-nom-imprime]').filter((noeud) => noeud.textContent.includes('Ombrelle Pervenche Ombrelle')); + const parListe = new Map(); + for (const morceau of morceaux) { + const liste = morceau.closest('[data-liste-imprimee]').dataset.listeImprimee; + if (!parListe.has(liste)) parListe.set(liste, []); + parListe.get(liste).push(morceau); + } + expect(parListe.size, 'la ligne du nom à chacun des deux tours').toBe(2); + // Ce que la cellule de la ligne continuée pose entière : le nom, le + // titre de la place, « non pourvu » s'il y a lieu (textesCellule), lu + // sur la source de l'aperçu. + const donnees = feuillesTables(charge, etatsDesTours(charge, m.ui.etat().proposition)); + const tours = Array.from({ length: charge.evenement.tours }, (_, rang) => rang + 1); + for (const [liste, suite] of parListe) { + const pages = new Set(suite.map((morceau) => morceau.closest('[data-feuille]').dataset.feuille)); + expect(pages.size, `liste ${liste} : la ligne continuée sur plusieurs feuilles`).toBeGreaterThan(1); + expect(plat(suite.map((morceau) => morceau.textContent).join('')), `liste ${liste} : le nom entier`).toBe(attendu); + + // La même ligne sur chacune de ses feuilles, dans leur ordre : ses + // parts mises bout à bout redonnent la cellule entière — le titre une + // seule fois, après le dernier morceau du nom. + const bloc = suite[0].closest('[data-liste-imprimee]'); + const rang = Number(suite[0].closest('[data-ligne-imprimee]').dataset.ligneImprimee); + const entete = [...bloc.querySelector('[data-entete-liste] tr').cells].slice(1).map(texte); + expect(entete, `liste ${liste} : un seul tour`).toHaveLength(1); + const tour = tours.find((candidat) => t('feuilles.tour', { tour: candidat }) === entete[0]); + const cellule = donnees.find(({ table }) => table === Number(bloc.dataset.table)).lignes[rang].cellules.find((c) => c.tour === tour); + expect(cellule.occupant, `liste ${liste}`).toBe(1); + expect(cellule.titre, `liste ${liste} : la place porte un titre`).not.toBeNull(); + const lignes = tous(m.racine, `[data-impression] [data-liste-imprimee="${liste}"] [data-ligne-imprimee="${rang}"]`); + expect(lignes.length, `liste ${liste}`).toBe(pages.size); + expect(lignes.map(texteDesDonnees).join(''), `liste ${liste} : la cellule entière`).toBe(textesCellule(cellule, t).join('')); + } + expect(dans(m, '[data-impression]').textContent).not.toContain(ABREVIATION); + }); +}); + +describe('la liste d’accueil : une ligne plus haute qu’une page (§ 7.4)', () => { + test('deux tours, A4 : continuée sous l’en-tête répété, dans les zones ; chaque texte une fois, dans l’ordre', async () => { + const charge = chargeNomDePages(2); + const m = await ouvrirApercu(contexte, charge, { section: 'participants' }); + await choisirVue(m, 'accueil'); + verifierDansLesZones(m); + + const lignes = tous(m.racine, '[data-impression] [data-participant="1"]'); + const pages = new Set(lignes.map((ligne) => ligne.closest('[data-feuille]').dataset.feuille)); + expect(pages.size, 'la ligne continuée sur plusieurs feuilles').toBeGreaterThan(1); + expect(lignes.length).toBe(pages.size); + expect( + lignes.map((ligne) => ligne.querySelector('[data-nom-imprime]')?.textContent ?? '').join(''), + 'le nom entier', + ).toBe(NOM_DE_PAGES); + const ligne = lignesAccueil(charge, etatsDesTours(charge, m.ui.etat().proposition), cleNormalisee).find(({ participant }) => participant === 1); + expect(lignes.map(texteDesDonnees).join(''), 'la ligne entière').toBe(textesLigneAccueil(ligne, t).join('')); + // Chaque autre personne garde sa ligne entière, sur une feuille. + const autres = tous(m.racine, '[data-impression] [data-participant]').filter((autre) => autre.dataset.participant !== '1'); + expect(autres).toHaveLength(charge.participants.filter(({ id, exclu }) => id !== 1 && !exclu).length); + expect(dans(m, '[data-impression]').textContent).not.toContain(ABREVIATION); + }); +}); diff --git a/src/interface/impression/listes.js b/src/interface/impression/listes.js index 31abd1b..64ff93c 100644 --- a/src/interface/impression/listes.js +++ b/src/interface/impression/listes.js @@ -142,6 +142,55 @@ export function feuillesTables(charge, etatsParTour, { parTour = false } = {}) { }); } +/** + * Les textes qu'une cellule de la feuille par table pose, dans l'ordre du + * document : le nom de l'occupant, ou la place vide ; le libellé du titre + * de la place ; « non pourvu » quand rien ne le pourvoit. Le texte vide + * pour ce que la cellule ne porte pas. Une ligne plus haute qu'une page se + * continue sur les suivantes par morceaux de cette suite (couperTexte, + * trancher). + * + * @param {{nom: string|null, titre: {libelle: string, pourvu: boolean}|null}} cellule + * @param {(cle: string) => string} t + * @returns {[string, string, string]} + */ +export function textesCellule({ nom, titre }, t) { + return [ + nom ?? t('feuilles.tables.vide'), + titre?.libelle ?? '', + titre !== null && !titre.pourvu ? t('feuilles.tables.titreNonPourvu') : '', + ]; +} + +// Un champ de la liste d'accueil tel qu'une ligne le pose : le texte vide +// pour un champ absent ou fait de blancs, qui ne pose aucun nœud de texte +// que couperLigne lise. +const champPose = (texte) => (texte === null || texte.trim() === '' ? '' : texte); + +/** + * Les textes qu'une ligne de la liste d'accueil pose, dans l'ordre du + * document : le nom, le prénom, l'appartenance, puis le libellé de la place + * à chaque tour (libellePlace). Le texte vide pour un champ absent ou fait + * de blancs. Une ligne plus haute qu'une page se continue sur les suivantes + * par morceaux de cette suite (couperLigne, trancher). + * + * @param {{nom: string, prenom: string|null, appartenance: string|null, + * places: Array<{table: number|null, siege: number|null}>}} ligne lignesAccueil + * @param {(cle: string, details?: Object) => string} t + * @returns {string[]} + */ +export function textesLigneAccueil({ nom, prenom, appartenance, places }, t) { + return [ + champPose(nom), + champPose(prenom), + champPose(appartenance), + ...places.map((place) => { + const { cle, details } = libellePlace(place); + return t(cle, details); + }), + ]; +} + /** * La feuille d'une page qui ne couvre qu'un tour (Q6 c) : le pied reçu * (feuilleDe), dont le nom de l'événement, sa date et la version restent, diff --git a/src/interface/impression/listes.test.js b/src/interface/impression/listes.test.js index 3a72687..d6f4381 100644 --- a/src/interface/impression/listes.test.js +++ b/src/interface/impression/listes.test.js @@ -7,7 +7,9 @@ // chaque tour ; les feuilles par table, les tours en colonnes ou une feuille // par tour (Q6 c) ; le pied et le filigrane d'une feuille d'un seul tour ; // les groupes de tours de la feuille par table, leur taille tirée de la -// part mesurée d'un tour, et leur resserrement sous une page seule. +// part mesurée d'un tour, et leur resserrement sous une page seule ; les +// textes d'une cellule de la feuille par table et d'une ligne de la liste +// d'accueil, dans l'ordre où elles les posent. // Les noms d'épreuve sont inventés, hors du réservoir des démonstrations. import assert from 'node:assert/strict'; import { describe, test } from '../../../test/lanceur.js'; @@ -26,6 +28,8 @@ import { libellePlace, lignesAccueil, resserrerGroupes, + textesCellule, + textesLigneAccueil, toursParGroupe, } from './listes.js'; @@ -404,3 +408,43 @@ describe('les groupes de tours de la feuille par table', () => { assert.deepEqual(resserrerGroupes([1, 5], unParGroupe, [...seuleDUnTour, { feuille: unParGroupe.length - 1, seule: true }]), [1, 3]); }); }); + +describe('textesCellule : les textes d’une cellule de la feuille par table, dans l’ordre où elle les pose', () => { + test('le nom ou la place vide, puis le libellé du titre, puis « non pourvu » ; le texte vide pour ce qui manque', () => { + assert.deepEqual(textesCellule({ tour: 1, occupant: 1, nom: 'Iris Ombrelle', titre: { libelle: 'animateur', pourvu: true } }, t), [ + 'Iris Ombrelle', + 'animateur', + '', + ]); + assert.deepEqual(textesCellule({ tour: 1, occupant: null, nom: null, titre: { libelle: 'hôte', pourvu: false } }, t), [ + t('feuilles.tables.vide'), + 'hôte', + t('feuilles.tables.titreNonPourvu'), + ]); + assert.deepEqual(textesCellule({ tour: 2, occupant: 3, nom: 'Théo Pervenche', titre: null }, t), ['Théo Pervenche', '', '']); + }); +}); + +describe('textesLigneAccueil : les textes d’une ligne de la liste d’accueil, dans l’ordre où elle les pose', () => { + test('le nom, le prénom, l’appartenance, puis le libellé de la place à chaque tour ; le texte vide pour un champ absent ou blanc', () => { + const places = [ + { tour: 1, table: 2, siege: null }, + { tour: 2, table: null, siege: null }, + { tour: 3, table: 4, siege: 5 }, + ]; + assert.deepEqual(textesLigneAccueil({ participant: 1, nom: 'Ombrelle', prenom: 'Iris', appartenance: 'Club des Merles', places }, t), [ + 'Ombrelle', + 'Iris', + 'Club des Merles', + t('feuilles.accueil.table', { table: 2 }), + t('feuilles.accueil.reserve'), + t('feuilles.accueil.tableSiege', { table: 4, siege: 5 }), + ]); + assert.deepEqual(textesLigneAccueil({ participant: 2, nom: 'Pervenche', prenom: null, appartenance: ' ', places: places.slice(1, 2) }, t), [ + 'Pervenche', + '', + '', + t('feuilles.accueil.reserve'), + ]); + }); +}); diff --git a/src/interface/impression/pagination.js b/src/interface/impression/pagination.js index 97e5b96..d4266b8 100644 --- a/src/interface/impression/pagination.js +++ b/src/interface/impression/pagination.js @@ -8,6 +8,13 @@ // choix de l'appelant — px CSS pour les vues. Chaque page répète l'en-tête // de sa liste. mesurerListes lit, sur une feuille rendue hors de la vue, ce // que la pagination reçoit. +// +// Une ligne plus haute qu'une page ne se coupe pas au bord de la page : son +// texte se partage en morceaux qui tiennent chacun sur une page sous +// l'en-tête (couperTexte), coupés au début d'un mot, et chaque morceau +// prend une page à la suite (morcelerPages). couperLigne essaie les +// morceaux sur une copie de la ligne rendue, hors de la vue ; trancher dit +// ce que chaque texte de la ligne garde dans un morceau. // L'écart qu'une somme de hauteurs peut avoir avec la hauteur utile et y // tenir encore : le bruit de l'arithmétique flottante (0,1 + 0,2 dépasse 0,3 @@ -127,3 +134,197 @@ export function mesurerPlace(sonde, tableau) { if (!(largeur > 0)) throw new RangeError(`mesurerPlace : tableau de largeur ${String(largeur)}`); return (100 * (sonde.getBoundingClientRect().width + ARRONDI_PX)) / largeur; } + +// Les débuts de caractère où un mot peut se couper : un point de code — +// jamais entre les deux unités UTF-16 d'un caractère hors du plan de base — +// qui n'est ni une marque combinante (sélecteurs de variante compris) ni un +// liant sans chasse (U+200D), et ne suit pas un liant. Une coupe ne sépare +// donc ni une lettre de son accent, ni les éléments d'une suite liée. L'expression ne +// dépend pas de la langue du système, que l'interface ne lit pas. +const DEBUT_DE_CARACTERE = /(? Array.isArray(textes) && textes.every((texte) => typeof texte === 'string'); + +/** + * Ce que chaque texte garde entre deux positions de leur suite mise bout à + * bout, en unités UTF-16 : la part de chacun qui tombe dans [debut, fin), le + * texte vide pour un texte qui n'y tombe pas. + * + * @param {string[]} textes + * @param {number} debut entier, de 0 à fin + * @param {number} fin entier, de debut à la longueur totale + * @returns {string[]} un par texte reçu + * @throws {TypeError} des textes qui ne sont pas une liste de chaînes + * @throws {RangeError} des positions hors du texte, ou dans le désordre + */ +export function trancher(textes, debut, fin) { + if (!sontDesTextes(textes)) throw new TypeError('trancher : une liste de textes attendue'); + const total = textes.reduce((somme, texte) => somme + texte.length, 0); + if (!Number.isSafeInteger(debut) || !Number.isSafeInteger(fin) || debut < 0 || fin < debut || fin > total) { + throw new RangeError(`trancher : [${String(debut)}, ${String(fin)}) hors de [0, ${total}]`); + } + let origine = 0; + return textes.map((texte) => { + const part = texte.slice(Math.max(0, debut - origine), Math.max(0, fin - origine)); + origine += texte.length; + return part; + }); +} + +// Les positions où un morceau peut finir, croissantes, dans ]0, total[ : +// mots, le début de chaque texte et de chaque mot ; caracteres, en plus, +// chaque début de caractère (DEBUT_DE_CARACTERE). +function coupesPossibles(textes) { + const mots = new Set(); + const caracteres = new Set(); + let origine = 0; + for (const texte of textes) { + mots.add(origine); + caracteres.add(origine); + for (const { index } of texte.matchAll(/(?<=\s)\S/gu)) mots.add(origine + index); + for (const { index } of texte.matchAll(DEBUT_DE_CARACTERE)) caracteres.add(origine + index); + origine += texte.length; + } + const croissantes = (positions) => [...positions].filter((p) => p > 0 && p < origine).sort((a, b) => a - b); + return { mots: croissantes(mots), caracteres: croissantes(caracteres), total: origine }; +} + +// La plus grande des positions (croissantes) de ]debut, borne[ où le +// morceau [debut, position) tient encore, par dichotomie — tient croît +// avec le morceau ; null quand aucune ne tient. +function plusGrandeQuiTient(positions, debut, borne, tient) { + const candidates = positions.filter((p) => p > debut && p < borne); + let bas = 0; + let haut = candidates.length; + while (bas < haut) { + const milieu = (bas + haut) >> 1; + if (tient(debut, candidates[milieu])) bas = milieu + 1; + else haut = milieu; + } + return bas === 0 ? null : candidates[bas - 1]; +} + +/** + * Les bornes des morceaux d'un texte trop haut pour une page, ses textes mis + * bout à bout : [0, c₁, …, total], chaque morceau [cᵢ, cᵢ₊₁) le plus long + * qui tient (tient), à partir de la fin du précédent. Un morceau finit au + * début d'un mot ou d'un texte ; un mot qui ne tient pas seul se coupe au + * dernier début de caractère qui tient (DEBUT_DE_CARACTERE), et le morceau + * suivant reprend aux mots. Quand rien ne tient, un morceau prend un + * caractère : le texte avance toujours. Un texte vide donne [0, 0]. + * + * La recherche est dichotomique : tient doit croître avec le morceau — un + * morceau qui tient, raccourci par la fin, tient encore. + * + * @param {string[]} textes + * @param {(debut: number, fin: number) => boolean} tient + * @returns {number[]} + * @throws {TypeError} des textes qui ne sont pas une liste de chaînes, un + * prédicat qui n'est pas une fonction + */ +export function couperTexte(textes, tient) { + if (!sontDesTextes(textes)) throw new TypeError('couperTexte : une liste de textes attendue'); + if (typeof tient !== 'function') throw new TypeError('couperTexte : un prédicat tient attendu'); + const { mots, caracteres, total } = coupesPossibles(textes); + const bornes = [0]; + let debut = 0; + while (debut < total) { + let fin = total; + if (!tient(debut, total)) { + // Le premier mot qui suit borne le repli aux caractères : au-delà, + // un morceau finirait dans un mot que l'on pouvait garder entier. + const motSuivant = mots.find((p) => p > debut) ?? total; + fin = + plusGrandeQuiTient(mots, debut, total, tient) ?? + plusGrandeQuiTient(caracteres, debut, motSuivant, tient) ?? + caracteres.find((p) => p > debut) ?? + total; + } + bornes.push(fin); + debut = fin; + } + if (total === 0) bornes.push(0); + return bornes; +} + +/** + * Les pages d'un document dont les lignes seules sont continuées : chaque + * page seule (paginerFeuilles) dont la ligne a des bornes à plus d'un + * morceau (couperTexte) devient une page par morceau, sous la même ligne, + * morceau {debut, fin} ; toute autre page reste telle, morceau null. rang + * renumérote les pages du document, de 0. + * + * @param {Array<{rang: number, feuille: number, debut: number, fin: number, seule: boolean}>} pages + * @param {Array<{feuille: number, ligne: number, bornes: number[]}>} coupures + * @returns {Array<{rang: number, feuille: number, debut: number, fin: number, seule: boolean, + * morceau: {debut: number, fin: number}|null}>} + * @throws {RangeError} des bornes qui ne partent pas de 0 ou ne croissent pas strictement + */ +export function morcelerPages(pages, coupures) { + const resultat = []; + for (const page of pages) { + const coupure = page.seule ? coupures.find(({ feuille, ligne }) => feuille === page.feuille && ligne === page.debut) : undefined; + const bornes = coupure?.bornes; + if (bornes !== undefined) { + const croissantes = bornes.every((borne, rang) => Number.isSafeInteger(borne) && (rang === 0 ? borne === 0 : borne > bornes[rang - 1])); + if (bornes.length < 2 || !croissantes) throw new RangeError(`morcelerPages : bornes ${JSON.stringify(bornes)}`); + } + if (bornes === undefined || bornes.length === 2) { + resultat.push({ ...page, rang: resultat.length, morceau: null }); + continue; + } + for (let rang = 1; rang < bornes.length; rang += 1) { + resultat.push({ ...page, rang: resultat.length, morceau: { debut: bornes[rang - 1], fin: bornes[rang] } }); + } + } + return resultat; +} + +/** + * Les bornes des morceaux d'une ligne plus haute qu'une page (couperTexte), + * lues sur le rendu : la ligne rendue dans sa liste, hors de la vue. Ses + * textes sont les nœuds de texte non blancs de ses cellules de données + * (td), dans l'ordre du document : une ligne de plusieurs cellules se + * continue cellule après cellule, et une page porte de chacune la part qui + * tombe dans son morceau (trancher). Les en-têtes de rangée (th) restent + * entiers sur chaque page. Un morceau tient quand une copie de la ligne, ses + * textes réduits à ce morceau, dans une copie du tableau à ses colonnes, + * mesure au plus hauteur ; la copie se retire avant le retour. + * + * @param {HTMLTableRowElement} ligne + * @param {number} hauteur en px CSS, ce que l'en-tête laisse d'une page + * @returns {number[]} + */ +export function couperLigne(ligne, hauteur) { + const tableau = ligne.closest('table'); + const essai = tableau.cloneNode(false); + for (const colonnes of tableau.querySelectorAll(':scope > colgroup')) essai.append(colonnes.cloneNode(true)); + const corps = document.createElement('tbody'); + const copie = ligne.cloneNode(true); + corps.append(copie); + essai.append(corps); + // Les nœuds de texte non blancs des cellules de données d'une rangée, + // dans l'ordre du document. + const noeuds = (rangee) => { + const lus = []; + for (const cellule of rangee.cells) { + if (cellule.tagName !== 'TD') continue; + const parcours = document.createTreeWalker(cellule, NodeFilter.SHOW_TEXT); + for (let noeud = parcours.nextNode(); noeud !== null; noeud = parcours.nextNode()) if (noeud.data.trim() !== '') lus.push(noeud); + } + return lus; + }; + const textes = noeuds(ligne).map((noeud) => noeud.data); + const copies = noeuds(copie); + tableau.after(essai); + try { + return couperTexte(textes, (debut, fin) => { + trancher(textes, debut, fin).forEach((part, rang) => { + copies[rang].data = part; + }); + return copie.getBoundingClientRect().height <= hauteur + BRUIT; + }); + } finally { + essai.remove(); + } +} diff --git a/src/interface/impression/pagination.test.js b/src/interface/impression/pagination.test.js index 7385799..c8f97f4 100644 --- a/src/interface/impression/pagination.test.js +++ b/src/interface/impression/pagination.test.js @@ -4,10 +4,12 @@ // La pagination des listes imprimées (§ 11.7), sans DOM : des hauteurs // mesurées, la hauteur utile d'une page et celle de l'en-tête que chaque // page répète, et les pages qu'elles donnent — aux bords exacts, une ligne -// plus haute qu'une page, une liste vide, plusieurs feuilles bout à bout. +// plus haute qu'une page, une liste vide, plusieurs feuilles bout à bout ; +// le texte d'une ligne plus haute qu'une page coupé en morceaux qui +// tiennent chacun sur une page, entre les mots, et ses pages de suite. import assert from 'node:assert/strict'; import { describe, test } from '../../../test/lanceur.js'; -import { mesurerPlace, paginer, paginerFeuilles } from './pagination.js'; +import { couperTexte, mesurerPlace, morcelerPages, paginer, paginerFeuilles, trancher } from './pagination.js'; describe('paginer : les lignes d’une liste en pages', () => { test('une somme exactement égale à la hauteur utile, en-tête compris, tient sur une page ; + 0,01 en donne deux', () => { @@ -129,3 +131,136 @@ describe('mesurerPlace : la part de la largeur d’une liste que prend son plus assert.throws(() => mesurerPlace(boite(10), boite(0)), RangeError); }); }); + +describe('trancher : la part de chaque fragment entre deux positions du texte mis bout à bout', () => { + test('chaque fragment garde ce qui tombe entre debut et fin, le texte vide sinon', () => { + const textes = ['Iris Ombrelle', 'hôte', '']; + assert.deepEqual(trancher(textes, 0, 17), textes); + assert.deepEqual(trancher(textes, 0, 5), ['Iris ', '', '']); + assert.deepEqual(trancher(textes, 5, 15), ['Ombrelle', 'hô', '']); + assert.deepEqual(trancher(textes, 13, 17), ['', 'hôte', '']); + assert.deepEqual(trancher(textes, 17, 17), ['', '', '']); + }); + + test('des positions hors du texte ou dans le désordre lèvent RangeError ; des fragments qui ne sont pas des textes, TypeError', () => { + for (const [debut, fin] of [[-1, 3], [3, 2], [0, 18], [0.5, 2], [Number.NaN, 2]]) { + assert.throws(() => trancher(['Iris Ombrelle', 'hôte'], debut, fin), RangeError, `${debut}..${fin}`); + } + assert.throws(() => trancher('Iris', 0, 1), TypeError); + assert.throws(() => trancher(['Iris', null], 0, 1), TypeError); + }); +}); + +describe('couperTexte : les bornes des morceaux d’un texte trop haut pour une page', () => { + // Un prédicat qui tient tout morceau d'au plus n unités, et le relevé de + // ses appels. + function auPlus(n) { + const appels = []; + const tient = (debut, fin) => { + appels.push([debut, fin]); + return fin - debut <= n; + }; + return { tient, appels }; + } + + test('un texte qui tient d’un bloc donne un seul morceau', () => { + assert.deepEqual(couperTexte(['Iris Ombrelle', 'hôte'], () => true), [0, 17]); + }); + + test('les morceaux se coupent au début d’un mot, le plus long qui tient ; le dernier finit le texte', () => { + const { tient } = auPlus(20); + const textes = ['Ombrelle Pervenche Ombrelle Pervenche']; + const bornes = couperTexte(textes, tient); + assert.deepEqual(bornes, [0, 19, 37]); + assert.deepEqual( + bornes.slice(1).map((fin, rang) => trancher(textes, bornes[rang], fin)[0]), + ['Ombrelle Pervenche ', 'Ombrelle Pervenche'], + ); + }); + + test('le début d’un fragment est une coupe : le titre passe entier sur la page suivante', () => { + assert.deepEqual(couperTexte(['Iris Ombrelle', 'hôte'], auPlus(13).tient), [0, 13, 17]); + }); + + test('un mot plus long qu’une page se coupe entre deux caractères : jamais avant un accent combinant, dans un caractère ou après un liant', () => { + assert.deepEqual(couperTexte(['Ombrelle'.repeat(5)], auPlus(15).tient), [0, 15, 30, 40]); + // Dix e à accent combinant, deux unités chacun : une coupe à 5 tomberait + // entre la lettre et sa marque. + assert.deepEqual(couperTexte(['e\u{301}'.repeat(10)], auPlus(5).tient), [0, 4, 8, 12, 16, 20]); + // Un caractère hors du plan de base, deux unités UTF-16. + assert.deepEqual(couperTexte(['\u{1F33C}'.repeat(3)], auPlus(3).tient), [0, 2, 4, 6]); + // Deux suites liées par U+200D, cinq unités chacune, plus longues que le + // morceau : chacune reste entière, ni coupée avant le liant ni après. + assert.deepEqual(couperTexte(['\u{1F33C}\u{200D}\u{1F33C}'.repeat(2)], auPlus(4).tient), [0, 5, 10]); + }); + + test('un mot coupé reprend aux mots : la coupe revient au début du mot suivant dès qu’elle tient', () => { + // « OmbrelleOmbrelle » passe la page de 10 : coupé à 10 ; le morceau + // suivant finit au début de « Pervenche ». + assert.deepEqual(couperTexte(['OmbrelleOmbrelle Pervenche'], auPlus(10).tient), [0, 10, 17, 26]); + }); + + test('rien ne tient : chaque morceau prend un caractère, le texte avance toujours', () => { + assert.deepEqual(couperTexte(['abc'], () => false), [0, 1, 2, 3]); + }); + + test('la recherche est dichotomique : quelques sondes par morceau, non une par mot', () => { + const { tient, appels } = auPlus(200); + const texte = 'Ombrelle Pervenche '.repeat(1500).trim(); + const bornes = couperTexte([texte], tient); + assert.equal(bornes[0], 0); + assert.equal(bornes.at(-1), texte.length); + assert.ok(bornes.length - 1 >= Math.ceil(texte.length / 200)); + for (let rang = 1; rang < bornes.length; rang += 1) { + assert.ok(bornes[rang] - bornes[rang - 1] <= 200, `morceau ${rang}`); + // Chaque coupe tombe au début d'un mot, et le mot suivant n'y tenait pas. + if (rang < bornes.length - 1) { + assert.match(texte.slice(bornes[rang]), /^Ombrelle|^Pervenche/, `coupe ${rang}`); + const suivant = texte.indexOf(' ', bornes[rang]) + 1; + assert.ok(suivant - bornes[rang - 1] > 200, `coupe ${rang} : le mot suivant tenait`); + } + } + assert.ok(appels.length <= 20 * (bornes.length - 1), `${appels.length} sondes pour ${bornes.length - 1} morceaux`); + }); + + test('un texte vide donne un seul morceau vide ; des fragments qui ne sont pas des textes lèvent TypeError', () => { + assert.deepEqual(couperTexte(['', ''], () => false), [0, 0]); + assert.throws(() => couperTexte([3], () => true), TypeError); + assert.throws(() => couperTexte(['Iris'], null), TypeError); + }); +}); + +describe('morcelerPages : une ligne seule, continuée sur autant de pages que de morceaux', () => { + test('chaque morceau d’une ligne seule prend une page, sous le même rang de ligne ; les autres pages restent, renumérotées', () => { + const pages = paginerFeuilles( + [ + { entete: 10, lignes: [20, 300, 20] }, + { entete: 10, lignes: [300] }, + ], + 100, + ); + assert.deepEqual(morcelerPages(pages, [{ feuille: 0, ligne: 1, bornes: [0, 40, 80, 95] }]), [ + { rang: 0, feuille: 0, debut: 0, fin: 1, seule: false, morceau: null }, + { rang: 1, feuille: 0, debut: 1, fin: 2, seule: true, morceau: { debut: 0, fin: 40 } }, + { rang: 2, feuille: 0, debut: 1, fin: 2, seule: true, morceau: { debut: 40, fin: 80 } }, + { rang: 3, feuille: 0, debut: 1, fin: 2, seule: true, morceau: { debut: 80, fin: 95 } }, + { rang: 4, feuille: 0, debut: 2, fin: 3, seule: false, morceau: null }, + { rang: 5, feuille: 1, debut: 0, fin: 1, seule: true, morceau: null }, + ]); + }); + + test('sans coupure, ou d’un seul morceau, une ligne seule garde sa page ; une coupure d’une ligne qui n’est pas seule ne change rien', () => { + const pages = paginerFeuilles([{ entete: 10, lignes: [20, 300] }], 100); + const attendues = pages.map((page) => ({ ...page, morceau: null })); + assert.deepEqual(morcelerPages(pages, []), attendues); + assert.deepEqual(morcelerPages(pages, [{ feuille: 0, ligne: 1, bornes: [0, 95] }]), attendues); + assert.deepEqual(morcelerPages(pages, [{ feuille: 0, ligne: 0, bornes: [0, 5, 9] }]), attendues); + }); + + test('des bornes qui ne partent pas de 0 ou ne croissent pas lèvent RangeError', () => { + const pages = paginerFeuilles([{ entete: 10, lignes: [300] }], 100); + for (const bornes of [[1, 5], [0, 5, 5], [0, 7, 3], [], [0, 2.5]]) { + assert.throws(() => morcelerPages(pages, [{ feuille: 0, ligne: 0, bornes }]), RangeError, JSON.stringify(bornes)); + } + }); +}); diff --git a/src/interface/parcours.navigateur.test.js b/src/interface/parcours.navigateur.test.js index da36e74..09173c8 100644 --- a/src/interface/parcours.navigateur.test.js +++ b/src/interface/parcours.navigateur.test.js @@ -17,7 +17,8 @@ // chargée, 11, 19, 20, puis 22 et 23, l'aperçu avant impression d'une // proposition et du retenu, puis 24 et 25, la feuille de contrôle et la // première planche du PDF que la section Badges produit, tramées sous node -// (§ 19.6) —, puis l'ordre de lecture du § 2.1 sur +// (§ 19.6) —, ces sorties en lecture puis sur un plan bloqué, qui n'écrivent +// que la planche et reglages_locaux.json (§ 11.6), puis l'ordre de lecture du § 2.1 sur // l'événement retenu, les fenêtres — aucune pendant un geste non // destructeur, une qui nomme ce qui part pour chaque geste qui détruit —, // enfin chaque famille de geste modifiant en lecture puis sur un plan @@ -716,6 +717,60 @@ async function photographierPdf(octets, { page, fichier }, conditions) { capturesPdf.add(fichier); } +// --- Sortir sans modifier (§ 11.6, § 8.4) ------------------------------------------ + +// Les sorties d'un événement ouvert : l'aperçu de chaque vue, envoyé à +// l'imprimante d'épreuve ; le papier de la machine changé, puis rendu ; +// la planche de badges produite par la section Badges. Le parcours +// reprend sur le plan. Rend le nom du PDF écrit. +async function sortir() { + const impressions = m.imprimante.appels().length; + const rang = m.trace.length; + await ouvrirSection('plan'); + await cliquer('[data-ecran="evenement"] [data-commande="apercu"]'); + await attendre('le document imprimé', () => dans('[data-impression] [data-feuille]')); + for (const vue of ['plan', 'accueil', 'tables']) { + await cliquer(`[data-ecran="apercu"] [data-commande="vue"][data-cible="${vue}"]`); + await attendre(`la vue ${vue}`, () => dans(`[data-impression][data-vue-impression="${vue}"] [data-feuille]`)); + await cliquer('[data-ecran="apercu"] [data-commande="imprimer"]'); + } + await attendre('trois impressions', () => m.imprimante.appels().length === impressions + 3); + for (const papier of ['Lettre', 'A4']) { + await cliquer('[data-ecran="apercu"] [data-papier="choisi"] [data-commande="changerPapier"]'); + await attendre('la question du papier', () => dans('[data-ecran="apercu"] [data-papier="question"]')); + await cliquer(`[data-papier="question"] [data-cible="${papier}"] [data-commande="choisirPapier"]`); + await attendre(`le papier ${papier}`, () => etat().papier === papier && dans('[data-papier="choisi"]') && dans('[data-impression] [data-feuille]')); + } + await cliquer('[data-ecran="apercu"] [data-commande="fermerApercu"]'); + await attendre('l’événement, de retour', () => dans('[data-ecran="evenement"]')); + await ouvrirSection('badges'); + await cliquer('[data-section="badges"] [data-commande="produirePlanche"]'); + await attendre('la planche produite', () => dans('[data-section="badges"] [data-chemin-planche]'), IMAGES_PLANCHE); + const planches = cheminsEcrits(rang).filter((chemin) => chemin.endsWith('.pdf')); + expect(planches, 'une planche écrite').toHaveLength(1); + await ouvrirSection('plan'); + return planches[0]; +} + +// « Regarder n'écrit jamais » (§ 8.2, § 8.4, § 11.6) : les deux écritures +// que regarder et sortir admettent — reglages_locaux.json, où s'écrivent +// le cadrage différé et le papier de la machine, et la planche de +// l'événement, .badges…pdf. Rien d'autre. +const ecritureAdmise = (chemin) => + chemin === 'reglages_locaux.json' || (chemin.startsWith(`${base()}.badges`) && chemin.endsWith('.pdf')); + +// Sort, puis affirme que seules les deux écritures admises ont eu lieu, +// chacune au moins une fois : rien de l'événement. +async function sortirSansEcrire(avant, empreinte, geste) { + const rang = m.trace.length; + const planche = await sortir(); + const interdites = m.trace.slice(rang).filter(([, , chemin]) => chemin !== '' && !ecritureAdmise(chemin)); + expect(interdites, geste).toEqual([]); + expect(cheminsEcrits(rang).sort(), geste).toEqual([planche, 'reglages_locaux.json'].sort()); + expect(serialiserCharge(charge())).toBe(empreinte); + await rienEcrit(avant, geste); +} + // --- Le parcours (§ 2.1, § 19.3) ------------------------------------------------------ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE }, () => { @@ -1529,15 +1584,58 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE await ouvrirSection('plan'); }); - // Un échec de 22 à 25 laisse l'aperçu ouvert, la proposition montrée, la - // section Badges ouverte ou la fenêtre défilée : l'état est rendu ici, - // pour que les épreuves suivantes jugent leurs propres gardes et non ce - // reste. + // Les sorties vivent en lecture et sur un plan bloqué, et n'écrivent que + // la planche et le papier de la machine (§ 11.6, § 8.4, § 9) ; le plan + // débloqué, le parcours reprend sur le retenu, en écriture. + test('lecture, puis bloqué : aperçu, impression, papier et planche ; seuls le PDF et reglages_locaux.json s’écrivent', async () => { + expect(charge().evenement.etat).toBe('retenu'); + await cliquer('[data-place-fixe] [data-commande="lecture"]'); + await attendre('la lecture', () => etat().mode === 'lecture'); + await sortirSansEcrire(fichiers(), serialiserCharge(charge()), 'lecture, sortir'); + + await cliquer('[data-place-fixe] [data-commande="modifier"]'); + await attendre('l’écriture', () => etat().mode === 'ecriture'); + let r = revision(); + await cliquer('[data-etat="bloque"] [data-commande="etat"]'); + await uneEntree(r, 'bloquer'); + expect(charge().evenement.etat).toBe('bloque'); + await sortirSansEcrire(fichiers(), serialiserCharge(charge()), 'bloqué, sortir'); + + r = revision(); + // « Débloquer » se confirme sur place : armée au premier appui, la + // commande exécute au second. + await cliquer('[data-place-fixe] [data-commande="debloquer"]'); + await cliquer('[data-place-fixe] [data-commande="debloquer"]'); + await uneEntree(r, 'débloquer'); + expect(charge().evenement.etat).toBe('retenu'); + if (etat().mode !== 'ecriture') { + await cliquer('[data-place-fixe] [data-commande="modifier"]'); + await attendre('l’écriture', () => etat().mode === 'ecriture'); + } + }); + + // Un échec de 22 aux sorties en lecture laisse l'aperçu ouvert, la + // proposition montrée, la section Badges ouverte, la fenêtre défilée, le + // plan bloqué ou en lecture : l'état est rendu ici, pour que les épreuves + // suivantes jugent leurs propres gardes et non ce reste. afterAll(async () => { if (dans('[data-ecran="apercu"] [data-commande="fermerApercu"]') !== null) { await cliquer('[data-ecran="apercu"] [data-commande="fermerApercu"]'); await attendre('le plan, de retour', () => dans('[data-ecran="evenement"]')); } + if (etat().mode === 'lecture' && dans('[data-place-fixe] [data-commande="modifier"]') !== null) { + await cliquer('[data-place-fixe] [data-commande="modifier"]'); + await attendre('l’écriture', () => etat().mode === 'ecriture'); + } + if (charge().evenement.etat === 'bloque' && dans('[data-place-fixe] [data-commande="debloquer"]') !== null) { + await cliquer('[data-place-fixe] [data-commande="debloquer"]'); + await cliquer('[data-place-fixe] [data-commande="debloquer"]'); + await attendre('le plan débloqué', () => charge().evenement.etat !== 'bloque'); + } + if (etat().mode === 'lecture' && dans('[data-place-fixe] [data-commande="modifier"]') !== null) { + await cliquer('[data-place-fixe] [data-commande="modifier"]'); + await attendre('l’écriture', () => etat().mode === 'ecriture'); + } if (m.ui.etat().proposition !== null && dans('[data-commande="quitterProposition"]') !== null) { await cliquer('[data-commande="quitterProposition"]'); await attendre('le retenu montré', () => m.ui.etat().proposition === null); @@ -1928,9 +2026,9 @@ describe('lecture et bloqué : tout geste modifiant refusé, rien d’écrit', { await oublierChoix(); } - // Les écritures relevées depuis le rang donné, hors du cadrage qui s'écrit - // différé dans reglages_locaux.json (contrainte 4). - const ecrituresDepuis = (rang) => m.trace.slice(rang).filter(([, , chemin]) => chemin !== '' && !chemin.endsWith('reglages_locaux.json')); + // Les écritures relevées depuis le rang donné, hors des deux que + // regarder admet (ecritureAdmise). + const ecrituresDepuis = (rang) => m.trace.slice(rang).filter(([, , chemin]) => chemin !== '' && !ecritureAdmise(chemin)); test('en lecture : quatorze familles refusées avec « Modifier », rien d’écrit ; tri, filtre, listes de travail vivants', async () => { await cliquer('[data-place-fixe] [data-commande="lecture"]'); @@ -1945,6 +2043,7 @@ describe('lecture et bloqué : tout geste modifiant refusé, rien d’écrit', { await regarderReste(); expect(serialiserCharge(charge())).toBe(empreinte); await rienEcrit(avant, 'lecture, regarder'); + expect(ecrituresDepuis(rang)).toEqual([]); }); test('plan bloqué : quatorze familles refusées avec « Débloquer », rien d’écrit ; tri, filtre, listes de travail vivants', async () => { @@ -1965,6 +2064,7 @@ describe('lecture et bloqué : tout geste modifiant refusé, rien d’écrit', { await regarderReste(); expect(serialiserCharge(charge())).toBe(empreinte); await rienEcrit(avant, 'bloqué, regarder'); + expect(ecrituresDepuis(rang)).toEqual([]); }); });
{ligne.nom} - {#if ligne.prenom !== null}{ligne.prenom}{/if} + {#if nom !== ''}{nom}{/if} - {#if ligne.appartenance !== null}{ligne.appartenance}{/if} - {t(libelle.cle, libelle.details)}{prenom}{appartenance}{place}