[FIX] print: continue overlong rows across pages, adopt bench values

A row taller than a page, on the table sheet or the welcome list, was
clipped by its page's content zone. It now continues piece by piece on
the next sheets under the repeated header, each cell's text once and in
order. SEUIL_SIEGES_LISIBLES (18 places) and TOLERANCE_CHAINE_TEMOIN
(0.011) move from working default to measured, matching the committed
bench reading. The walkthrough checks that leaving the read-only mode
writes only the badge PDF and the local paper setting.
Checked: 3853 node, 761 browser, 101 long tests, from the index alone.

--- FR ---

[FIX] impression : lignes trop hautes continuées, banc adopté

Une ligne plus haute qu'une page, sur la feuille par table ou la liste
d'accueil, était rognée par la zone de contenu. Elle se continue
désormais par morceaux sur les feuilles suivantes, sous l'en-tête
répété, chaque texte une fois et dans l'ordre. SEUIL_SIEGES_LISIBLES
(18 places) et TOLERANCE_CHAINE_TEMOIN (0,011) passent de défaut de
travail à mesurées, d'accord avec le relevé commis. Le parcours vérifie
que sortir de la lecture seule n'écrit que le PDF et le papier local.
Vérifié : 3853 node, 761 navigateur, 101 longues, depuis l'index seul.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-10 17:21:55 -04:00
parent c69c0f320b
commit 01b5582f62
12 changed files with 827 additions and 69 deletions

View file

@ -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 |
<!-- engendre:fin constantes -->

View file

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

View file

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

View file

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

View file

@ -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 @@
});
</script>
{#snippet celluleTour(cellule)}
<!-- Une cellule de tour : ses textes entiers (textesCellule), ou les parts
de ses textes qu'une page porte d'une ligne continuée. -->
{#snippet celluleTour(cellule, parts = null)}
{@const [nom, titre, nonPourvu] = parts ?? textesCellule(cellule, t)}
<td>
{#if cellule.nom === null}
<span class="vide" data-place-vide>{t('feuilles.tables.vide')}</span>
{:else}
<span class="nom" data-nom-imprime>{cellule.nom}</span>
{#if nom !== ''}
{#if cellule.nom === null}
<span class="vide" data-place-vide>{nom}</span>
{:else}
<span class="nom" data-nom-imprime>{nom}</span>
{/if}
{/if}
{#if cellule.titre !== null}
{#if titre !== '' || nonPourvu !== ''}
<span class="titre-place">
<span class="libelle-titre">{cellule.titre.libelle}</span>
{#if !cellule.titre.pourvu}
<span>{t('feuilles.tables.titreNonPourvu')}</span>
{#if titre !== ''}
<span class="libelle-titre">{titre}</span>
{/if}
{#if nonPourvu !== ''}
<span>{nonPourvu}</span>
{/if}
</span>
{/if}
@ -142,7 +175,7 @@
</table>
{/snippet}
{#snippet blocTable(liste, rangListe, debut, fin)}
{#snippet blocTable(liste, rangListe, debut, fin, morceau = null)}
<div class="liste" data-liste-imprimee={rangListe} data-table={liste.table}>
<p class="titre">{t('feuilles.tables.titre', { table: liste.numero })}</p>
<table>
@ -161,10 +194,14 @@
</thead>
<tbody>
{#each liste.lignes.slice(debut, fin) as ligne, rang (ligne.place)}
<!-- Le morceau {debut, fin} d'une ligne continuée porte sur les
textes de ses cellules mis bout à bout, dans l'ordre du
document, comme couperLigne les lit : trois par cellule. -->
{@const parts = morceau === null ? null : trancher(ligne.cellules.flatMap((cellule) => textesCellule(cellule, t)), morceau.debut, morceau.fin)}
<tr data-ligne-imprimee={debut + rang}>
<th scope="row">{ligne.place}</th>
{#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}
</tr>
{/each}
@ -201,7 +238,7 @@
{#each pages as page (page.rang)}
<Feuille rang={page.rang} papier={options.papier} orientation="portrait" feuille={feuilleDeListe(groupes[page.feuille])} {bandeMm}>
{#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}
</Feuille>
{/each}

View file

@ -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 @@
});
</script>
{#snippet liste(debut, fin)}
<!-- La liste, de la ligne debut (comprise) à fin (exclue) ; sous un
morceau {debut, fin}, chaque cellule de la ligne continuée ne pose que
la part de ses textes (textesLigneAccueil) qui tombe dans ce morceau. -->
{#snippet liste(debut, fin, morceau = null)}
<div class="liste" data-liste-imprimee={0}>
<p class="titre">{t('feuilles.accueil.titre')}</p>
<table>
@ -120,17 +139,16 @@
</thead>
<tbody>
{#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)}
<tr data-ligne-imprimee={debut + rang} data-participant={ligne.participant}>
<td><span class="nom" data-nom-imprime>{ligne.nom}</span></td>
<td>
{#if ligne.prenom !== null}{ligne.prenom}{/if}
{#if nom !== ''}<span class="nom" data-nom-imprime>{nom}</span>{/if}
</td>
<td>
{#if ligne.appartenance !== null}{ligne.appartenance}{/if}
</td>
{#each ligne.places as place (place.tour)}
{@const libelle = libellePlace(place)}
<td>{t(libelle.cle, libelle.details)}</td>
<td>{prenom}</td>
<td>{appartenance}</td>
{#each places as place, rangPlace (rangPlace)}
<td>{place}</td>
{/each}
</tr>
{/each}
@ -163,7 +181,7 @@
{#each pages as page (page.rang)}
<Feuille rang={page.rang} papier={options.papier} orientation="portrait" {feuille} {bandeMm}>
{#snippet contenu()}
{@render liste(page.debut, page.fin)}
{@render liste(page.debut, page.fin, page.morceau)}
{/snippet}
</Feuille>
{/each}

View file

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

View file

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

View file

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

View file

@ -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 = /(?<!\u{200D})[^\p{M}\u{200D}]/gu;
const sontDesTextes = (textes) => 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();
}
}

View file

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

View file

@ -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, <base>.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([]);
});
});