diff --git a/src/pdf/mise_en_page.js b/src/pdf/mise_en_page.js new file mode 100644 index 0000000..00143d4 --- /dev/null +++ b/src/pdf/mise_en_page.js @@ -0,0 +1,435 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La mise en page d'un badge (§ 11.4, § 11.5) : où tombe chaque ligne de +// texte dans le cadre utile, à quel corps, en quelle graisse. Module pur : +// toute mesure de texte passe par les métriques reçues, celles de la police +// embarquée lues par pdf-lib (metriquesDe) ou celles d'une épreuve ; aucune +// cote de glyphe n'y est posée. Les cotes sont en millimètres, l'origine au +// coin haut gauche du cadre utile, y sur la ligne de base ; les corps en +// points. +// +// La page est un flux. En haut, le nom en gras, puis l'appartenance, puis +// les lignes de tour ; en bas, le pied, sa dernière ligne posée sur le bord +// du cadre. Chaque ligne occupe la hauteur que la police lui donne, de +// l'ascendante à la descendante : deux lignes empilées ne se recouvrent +// jamais, et une ligne absente laisse sa place à la suivante. +// +// Le nom se choisit d'abord, sur la seule largeur : il passe avant tout le +// reste (§ 11.4, rang 1). Pour chaque forme du nom, de la plus grande au +// plancher, les tours essaient leurs dispositions, une colonne du corps des +// tours au plancher, puis deux colonnes ; la première qui tient l'emporte. +// Le nom au plancher, le badge se dépouille dans l'ordre de sacrifice +// (décision 8). Retirer un texte ne vient qu'après avoir réduit tout ce qui +// se réduit. +import { constante } from '../geometrie/constantes.js'; +import { BADGE } from '../geometrie/papiers.js'; +import { cadreUtile } from './imposition.js'; + +/** Le point typographique en millimètres. */ +const MM_PAR_PT = 25.4 / 72; + +/** La marque d'abrègement d'une appartenance ou d'un titre. */ +const MARQUE = '\u{2026}'; + +/** Le séparateur que les libellés d'une ligne de tour posent devant la + * place, puis devant le titre : « Tour 2 — table 5, siège 3 — titre ». */ +const SEPARATEUR = ' \u{2014} '; + +/** La tolérance d'une comparaison de cotes, en millimètres : une somme de + * hauteurs égale à la hauteur utile tient, malgré l'arrondi flottant. */ +const TOLERANCE_MM = 1e-9; + +// Une grappe : un point de code suivi de ses marques combinantes. Une coupe +// entre deux grappes ne sépare jamais une lettre de son accent ; une marque +// en tête de texte forme sa propre grappe. +const GRAPPE = /\P{M}\p{M}*|\p{M}+/gu; + +/** + * Les mesures de texte dont la mise en page a besoin, en millimètres. + * + * @typedef {Object} Metriques + * @property {(texte: string, corps: number, gras: boolean) => number} largeur + * @property {(corps: number) => {ascendante: number, descendante: number, capitale: number}} hauteurs + * proportionnelles au corps ; la descendante est positive + * @property {(texte: string) => string[]} absents les caractères sans + * glyphe, une fois chacun, dans l'ordre de première rencontre + */ + +// La police fontkit qu'une PDFFont de pdf-lib porte, quand elle a été +// embarquée d'un fichier par fontkit ; TypeError sinon (police standard, +// valeur quelconque). +function fonteDe(police, graisse) { + const fonte = police?.embedder?.font; + if (typeof fonte?.hasGlyphForCodePoint !== 'function' || !Number.isFinite(fonte?.unitsPerEm)) { + throw new TypeError(`metriquesDe : police ${graisse} embarquée par fontkit attendue`); + } + return fonte; +} + +/** + * Les métriques des deux graisses de la police embarquée. La largeur est + * celle que pdf-lib dessine (widthOfTextAtSize : les avances des glyphes + * après substitution des ligatures). Les hauteurs viennent de la table de + * la police, la plus grande des deux graisses pour chacune : une ligne + * réservée à leur hauteur contient l'une comme l'autre. Un caractère est + * absent quand l'une des deux graisses n'a pas son glyphe. + * + * @param {{regulier: import('pdf-lib').PDFFont, gras: import('pdf-lib').PDFFont}} polices + * @returns {Metriques} + * @throws {TypeError} une police qui n'a pas été embarquée par fontkit + */ +export function metriquesDe({ regulier, gras }) { + const fontes = [fonteDe(regulier, 'regulier'), fonteDe(gras, 'gras')]; + const rapport = (lire) => Math.max(...fontes.map((f) => lire(f) / f.unitsPerEm)); + const ascendante = rapport((f) => f.ascent); + const descendante = rapport((f) => -f.descent); + const capitale = rapport((f) => f.capHeight); + // La largeur d'un texte au corps 1000, par graisse : pdf-lib la rend + // proportionnelle au corps (somme des avances × corps / 1000), et la mise en + // page mesure le même texte à chaque palier. Une mise en forme par fontkit + // par texte suffit ; le produit par corps / 1000 refait l'opération de + // pdf-lib à l'identique. + const avances = [new Map(), new Map()]; + const largeurMille = (texte, enGras) => { + const memoire = avances[enGras ? 1 : 0]; + let mille = memoire.get(texte); + if (mille === undefined) { + mille = (enGras ? gras : regulier).widthOfTextAtSize(texte, 1000); + memoire.set(texte, mille); + } + return mille; + }; + return { + largeur: (texte, corps, enGras) => largeurMille(texte, enGras) * (corps / 1000) * MM_PAR_PT, + hauteurs: (corps) => ({ + ascendante: ascendante * corps * MM_PAR_PT, + descendante: descendante * corps * MM_PAR_PT, + capitale: capitale * corps * MM_PAR_PT, + }), + absents: (texte) => { + const vus = new Set(); + const absents = []; + for (const caractere of texte) { + if (vus.has(caractere)) continue; + vus.add(caractere); + const point = caractere.codePointAt(0); + if (!fontes.every((f) => f.hasGlyphForCodePoint(point))) absents.push(caractere); + } + return absents; + }, + }; +} + +// Les corps d'une réduction par paliers d'un point, du départ au plancher, +// le plancher compris même quand l'écart n'est pas un nombre entier de +// points. Un départ sous le plancher ne garde que le plancher. +function paliers(depart, plancher) { + const corps = []; + for (let c = depart; c > plancher + TOLERANCE_MM; c -= 1) corps.push(c); + corps.push(plancher); + return corps; +} + +/** + * La mise en page d'un badge, ou le refus qui dit pourquoi il ne tient pas. + * + * Nom : le corps de départ donne au nom la hauteur de capitale maximale du + * registre, et descend d'un point par palier jusqu'au plancher, dont la + * capitale vaut le plancher du registre ; les deux se convertissent par la + * capitale que les métriques donnent. Le nom tient d'abord sur une ligne, + * puis sur deux, puis sur trois, chaque fois au plus grand corps possible, + * coupé aux espaces ; à défaut seulement, coupé entre deux grappes, de deux + * à trois lignes. Quand le reste du badge ne tient pas sous ce nom, il + * descend d'un palier à la fois jusqu'au plancher, au même nombre de lignes + * au plus. Au plancher, ou au-delà de trois lignes, où il se replie au + * plancher sur autant de lignes qu'il lui faut, le badge perd le nom de + * l'événement (le pied devient le pied réduit), puis, s'il ne tient + * toujours pas, l'appartenance. Le nom ne s'abrège jamais. + * + * Appartenance : une ligne, abrégée par « … » au plus long préfixe qui + * tient. Pied : chaque ligne se replie comme le nom, rien ne s'y abrège. + * + * Tours : une colonne, du corps des tours à leur plancher, puis deux + * colonnes, la première rangée de haut en bas d'abord, séparées d'un + * cadratin. Chaque ligne est entière, sauf au plancher : une ligne trop + * large pour sa colonne y abrège son titre, jamais sa place ; une ligne sans + * titre trop large ne se pose pas. Aucun corps ne dépasse celui du nom. + * + * Refus : PLANCHE_NOM_TROP_LONG quand le nom dépasse trois lignes et que + * rien ne tient, PLANCHE_TROP_DE_TOURS {tours} sinon, tours étant le nombre + * de lignes de tour. src/pdf ne connaît pas la personne : l'appelant + * complète les détails. + * + * @param {{nom: string, lignes: string[], appartenance: string|null, + * pied: string[], piedReduit: string[]}} textes + * @param {Metriques} metriques + * @returns {{blocs: Array<{texte: string, corps: number, gras: boolean, x: number, y: number}>, + * colonnesTours: 1|2} + * | {refus: {code: string, details: object}}} + */ +export function mettreEnPage(textes, metriques) { + const { largeur: L, hauteur: H } = cadreUtile({ x: 0, y: 0, largeur: BADGE.largeur, hauteur: BADGE.hauteur }); + const memoire = new Map(); + const largeur = (texte, corps, gras) => { + const cle = `${gras ? 'g' : 'r'}${corps}\u{0}${texte}`; + if (!memoire.has(cle)) memoire.set(cle, metriques.largeur(texte, corps, gras)); + return memoire.get(cle); + }; + const tient = (texte, corps, gras, largeurMax = L) => largeur(texte, corps, gras) <= largeurMax + TOLERANCE_MM; + const pas = (corps) => { + const { ascendante, descendante } = metriques.hauteurs(corps); + return { ascendante, descendante, hauteur: ascendante + descendante }; + }; + + // Les lignes du texte repliées dans la largeur, au plus `limite` ; null + // quand il en faut davantage, ou qu'une grappe seule déborde. Un mot qui + // ne tient pas seul sur une ligne se coupe entre deux grappes, sauf sous + // `motsEntiers`, où le repli échoue. + function replier(texte, corps, gras, limite, motsEntiers = false) { + const lignes = []; + let courante = ''; + const clore = () => { + lignes.push(courante); + courante = ''; + return lignes.length <= limite; + }; + for (const mot of texte.split(' ')) { + if (mot === '') continue; + const avecMot = courante === '' ? mot : `${courante} ${mot}`; + if (tient(avecMot, corps, gras)) { + courante = avecMot; + continue; + } + if (courante !== '' && !clore()) return null; + if (tient(mot, corps, gras)) { + courante = mot; + continue; + } + if (motsEntiers) return null; + for (const grappe of mot.match(GRAPPE)) { + const avecGrappe = courante + grappe; + if (courante === '' || tient(avecGrappe, corps, gras)) { + courante = avecGrappe; + } else if (!clore()) { + return null; + } else { + courante = grappe; + } + if (!tient(courante, corps, gras)) return null; + } + } + if (courante !== '' && !clore()) return null; + return lignes; + } + + // Le texte entier s'il tient, sinon son plus long préfixe, espaces finales + // retirées, suivi de la marque ; null si la marque seule déborde. Les + // avances sont positives ou nulles : la largeur d'un préfixe croît avec + // son nombre de grappes, une dichotomie trouve le plus long qui tient seul, + // et aucun préfixe plus long ne tient avec la marque. De là, le premier qui + // tient avec elle, en descendant. + function abreger(texte, corps, gras, largeurMax) { + if (tient(texte, corps, gras, largeurMax)) return texte; + const grappes = texte.match(GRAPPE); + const prefixe = (nombre) => grappes.slice(0, nombre).join(''); + // Le préfixe vide tient, le texte entier non. + let bas = 0; + let haut = grappes.length - 1; + while (bas < haut) { + const milieu = Math.ceil((bas + haut) / 2); + if (tient(prefixe(milieu), corps, gras, largeurMax)) bas = milieu; + else haut = milieu - 1; + } + for (let nombre = bas; nombre > 0; nombre -= 1) { + const candidat = `${prefixe(nombre).replace(/ +$/u, '')}${MARQUE}`; + if (tient(candidat, corps, gras, largeurMax)) return candidat; + } + return tient(MARQUE, corps, gras, largeurMax) ? MARQUE : null; + } + + // Une ligne de tour dans la largeur : entière si elle tient ; sinon + // abrégée dans son titre, qui suit le second séparateur, sa place (tour, + // table, siège) gardée entière et au moins une grappe du titre ; null pour + // une ligne sans titre, ou dont la place et une grappe, suivies de la + // marque, débordent. Le plus long préfixe qu'abreger rend ne couvre pas + // la place : aucun autre ne la couvre. + function abregerTitre(ligne, corps, largeurMax) { + if (tient(ligne, corps, false, largeurMax)) return ligne; + const premier = ligne.indexOf(SEPARATEUR); + const finDeLaPlace = premier < 0 ? -1 : ligne.indexOf(SEPARATEUR, premier + SEPARATEUR.length); + if (finDeLaPlace < 0) return null; + const abregee = abreger(ligne, corps, false, largeurMax); + // Au moins une grappe du titre reste : la place suivie du seul séparateur + // et de la marque se lirait comme une place abrégée. + const titreGarde = abregee !== null && abregee.length - MARQUE.length > finDeLaPlace + SEPARATEUR.trimEnd().length; + return titreGarde ? abregee : null; + } + + // Les formes du nom, dans l'ordre où le badge les essaie, chacune + // {corps, lignes}. La première est la plus grande qui tient en largeur : + // de une à trois lignes, du corps maximal au plancher, coupées aux + // espaces ; à défaut seulement, coupées entre deux grappes. Suivent les + // corps plus petits jusqu'au plancher, au même nombre de lignes au plus, + // qui libèrent de la hauteur. Au-delà de trois lignes, une forme seule : + // le plancher, sur autant de lignes qu'il en faut, bornées par ce que la + // hauteur utile empile. + const capitaleParPt = metriques.hauteurs(1).capitale; + const corpsNomMax = constante('HAUTEUR_CAPITALE_NOM_MAX_MM') / capitaleParPt; + const corpsNomPlancher = constante('HAUTEUR_CAPITALE_NOM_PLANCHER_MM') / capitaleParPt; + const paliersNom = paliers(corpsNomMax, corpsNomPlancher); + function choisirNom() { + for (const motsEntiers of [true, false]) { + for (let nombre = 1; nombre <= 3; nombre += 1) { + const premier = paliersNom.findIndex((corps) => replier(textes.nom, corps, true, nombre, motsEntiers) !== null); + if (premier < 0) continue; + const formes = []; + for (const corps of paliersNom.slice(premier)) { + const lignes = replier(textes.nom, corps, true, nombre, motsEntiers); + if (lignes !== null) formes.push({ corps, lignes }); + } + return { formes, auDela: false }; + } + } + const empilables = Math.floor((H + TOLERANCE_MM) / pas(corpsNomPlancher).hauteur); + const lignes = + replier(textes.nom, corpsNomPlancher, true, empilables, true) ?? + replier(textes.nom, corpsNomPlancher, true, empilables, false); + return lignes === null ? null : { formes: [{ corps: corpsNomPlancher, lignes }], auDela: true }; + } + const refusNom = { refus: { code: 'PLANCHE_NOM_TROP_LONG', details: {} } }; + const nom = choisirNom(); + if (nom === null) return refusNom; + + const piedReplie = (lignes, corps) => { + const resultat = []; + for (const ligne of lignes) { + const repliee = replier(ligne, corps, false, Infinity); + if (repliee === null) return null; + resultat.push(...repliee); + } + return resultat; + }; + const centre = (texte, corps, gras) => (L - largeur(texte, corps, gras)) / 2; + + // Le badge sous une forme du nom, avec ou sans le nom de l'événement et + // l'appartenance ; null quand aucune disposition des tours n'y tient. + function composer(forme, { evenement, avecAppartenance }) { + const auPlusNom = (corps) => Math.min(corps, forme.corps); + const corpsAppartenance = auPlusNom(constante('CORPS_APPARTENANCE_PT')); + const corpsPied = auPlusNom(constante('CORPS_PIED_PT')); + const corpsTours = paliers(auPlusNom(constante('CORPS_TOURS_PT')), auPlusNom(constante('CORPS_TOURS_PLANCHER_PT'))); + const plancherTours = corpsTours.at(-1); + + const appartenance = + !avecAppartenance || textes.appartenance === null || textes.appartenance.trim() === '' + ? null + : abreger(textes.appartenance, corpsAppartenance, false, L); + const pied = piedReplie(evenement ? textes.pied : textes.piedReduit, corpsPied); + if (pied === null) return null; + + // Les lignes de tour d'une disposition, avec leur abscisse et leur rang ; + // null quand l'une ne tient pas dans sa colonne. + function disposerTours({ colonnes, corps }) { + const poser = (ligne, largeurMax) => + corps === plancherTours ? abregerTitre(ligne, corps, largeurMax) : tient(ligne, corps, false, largeurMax) ? ligne : null; + const poserColonne = (colonne, largeurMax) => { + const posees = colonne.map((ligne) => poser(ligne, largeurMax)); + return posees.includes(null) ? null : posees; + }; + const largeurColonne = (colonne) => Math.max(0, ...colonne.map((t) => largeur(t, corps, false))); + const lignes = textes.lignes; + if (colonnes === 1) { + const posees = poserColonne(lignes, L); + if (posees === null) return null; + const x = (L - largeurColonne(posees)) / 2; + return { lignes: posees.map((texte, rang) => ({ texte, x, rang })) }; + } + // La colonne de gauche reçoit ce que la droite, entière, lui laisse, et + // jamais moins d'une demi-colonne ; la droite, ce que la gauche posée + // lui laisse. Les deux tiennent ensemble par construction, et des + // lignes entières qui tiennent ensemble passent telles quelles. + const rangees = Math.ceil(lignes.length / 2); + const gouttiere = corps * MM_PAR_PT; + const demi = (L - gouttiere) / 2; + const droiteEntiere = lignes.slice(rangees); + const gauche = poserColonne(lignes.slice(0, rangees), L - gouttiere - Math.min(largeurColonne(droiteEntiere), demi)); + if (gauche === null) return null; + const droite = poserColonne(droiteEntiere, L - gouttiere - largeurColonne(gauche)); + if (droite === null) return null; + const x = (L - (largeurColonne(gauche) + gouttiere + largeurColonne(droite))) / 2; + const xDroite = x + largeurColonne(gauche) + gouttiere; + return { + lignes: [ + ...gauche.map((texte, rang) => ({ texte, x, rang })), + ...droite.map((texte, rang) => ({ texte, x: xDroite, rang })), + ], + }; + } + + const pasPied = pas(corpsPied); + const hautDuPied = H - pied.length * pasPied.hauteur; + // Le flux du haut, jusqu'aux tours. + const blocs = []; + let curseur = 0; + const pasNom = pas(forme.corps); + for (const texte of forme.lignes) { + const y = curseur + pasNom.ascendante; + blocs.push({ texte, corps: forme.corps, gras: true, x: centre(texte, forme.corps, true), y }); + curseur = y + pasNom.descendante; + } + if (appartenance !== null) { + const pasAppartenance = pas(corpsAppartenance); + const y = curseur + pasAppartenance.ascendante; + blocs.push({ texte: appartenance, corps: corpsAppartenance, gras: false, x: centre(appartenance, corpsAppartenance, false), y }); + curseur = y + pasAppartenance.descendante; + } + // Les dispositions des tours, dans l'ordre de préférence : une colonne + // du corps des tours au plancher, puis deux. + const dispositions = [ + ...corpsTours.map((corps) => ({ colonnes: 1, corps })), + ...corpsTours.map((corps) => ({ colonnes: 2, corps })), + ]; + for (const disposition of dispositions) { + const pasTours = pas(disposition.corps); + if (curseur + Math.ceil(textes.lignes.length / disposition.colonnes) * pasTours.hauteur > hautDuPied + TOLERANCE_MM) { + continue; + } + const tours = disposerTours(disposition); + if (tours === null) continue; + const blocsTours = tours.lignes.map(({ texte, x, rang }) => ({ + texte, + corps: disposition.corps, + gras: false, + x, + y: curseur + rang * pasTours.hauteur + pasTours.ascendante, + })); + const blocsPied = pied.map((texte, rang) => ({ + texte, + corps: corpsPied, + gras: false, + x: centre(texte, corpsPied, false), + y: hautDuPied + rang * pasPied.hauteur + pasPied.ascendante, + })); + return { blocs: [...blocs, ...blocsTours, ...blocsPied], colonnesTours: disposition.colonnes }; + } + return null; + } + + // Les essais, du badge complet au plus dépouillé : chaque forme du nom + // avec le badge entier, puis le nom au plancher sans le nom de + // l'événement, puis sans l'appartenance. Un nom au-delà de trois lignes a + // déjà perdu le nom de l'événement. + const plancherNom = nom.formes.at(-1); + const essais = [ + ...(nom.auDela ? [] : nom.formes.map((forme) => ({ forme, evenement: true, avecAppartenance: true }))), + { forme: plancherNom, evenement: false, avecAppartenance: true }, + { forme: plancherNom, evenement: false, avecAppartenance: false }, + ]; + for (const { forme, ...niveau } of essais) { + const page = composer(forme, niveau); + if (page !== null) return page; + } + if (nom.auDela) return refusNom; + return { refus: { code: 'PLANCHE_TROP_DE_TOURS', details: { tours: textes.lignes.length } } }; +} diff --git a/src/pdf/mise_en_page.test.js b/src/pdf/mise_en_page.test.js new file mode 100644 index 0000000..45368b4 --- /dev/null +++ b/src/pdf/mise_en_page.test.js @@ -0,0 +1,736 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La mise en page du badge (§ 11.4, § 11.5), éprouvée sur deux jeux de +// métriques : celles de la police embarquée, lues par pdf-lib, et des +// métriques factices à chasse fixe dont chaque cote se calcule de tête. +// Chaque épreuve affirme ce que voit le porteur du carton : le nom jamais +// abrégé et toujours dans le cadre utile, l'ordre de sacrifice, une +// appartenance sur une ligne, des tours jamais tronqués, aucun texte qui en +// chevauche un autre. +import assert from 'node:assert/strict'; +import fontkit from '@pdf-lib/fontkit'; +import { PDFDocument, StandardFonts } from 'pdf-lib'; +import { lirePolices } from '../../test/polices.js'; +import { describe, test } from '../../test/lanceur.js'; +import { constante } from '../geometrie/constantes.js'; +import { BADGE } from '../geometrie/papiers.js'; +import { cadreUtile } from './imposition.js'; +import { mettreEnPage, metriquesDe } from './mise_en_page.js'; + +// Le point typographique en millimètres : une conversion d'unités. +const MM_PAR_PT = 25.4 / 72; +const CADRE = cadreUtile({ x: 0, y: 0, largeur: BADGE.largeur, hauteur: BADGE.hauteur }); +const TOLERANCE = 1e-9; +const MARQUE = '\u{2026}'; +const TIRET = '\u{2014}'; + +const polices = lirePolices(); +const document = await PDFDocument.create(); +document.registerFontkit(fontkit); +const regulierPdf = await document.embedFont(polices.regulier, { subset: true }); +const grasPdf = await document.embedFont(polices.gras, { subset: true }); +const NOTO = metriquesDe({ regulier: regulierPdf, gras: grasPdf }); + +// Chasse fixe : chaque point de code avance de `chasse` cadratin, sauf les +// capitales larges W, M, Æ et Œ, qui avancent du double — le glyphe le plus +// large de l'alphabet, sur lequel le corps du nom se cale (§ 11.5) — et les +// marques combinantes, qui avancent de `marque` fois la chasse (nulle, comme +// dans la police embarquée, ou non, comme dans une police qui les espace) ; +// un dixième de plus en gras. La capitale vaut `capitale` cadratin. Les +// glyphes au-delà de U+024F sont absents. +function metriquesFactices({ capitale = 0.7, chasse = 0.45, marque = 0 } = {}) { + const avance = (c) => (/\p{M}/u.test(c) ? marque : 'WMÆŒ'.includes(c) ? 2 : 1); + const avances = (texte) => [...texte].reduce((somme, c) => somme + avance(c), 0); + return { + largeur: (texte, corps, gras) => avances(texte) * chasse * (gras ? 1.1 : 1) * corps * MM_PAR_PT, + hauteurs: (corps) => ({ + ascendante: 0.95 * corps * MM_PAR_PT, + descendante: 0.25 * corps * MM_PAR_PT, + capitale: capitale * corps * MM_PAR_PT, + }), + absents: (texte) => [...new Set([...texte].filter((c) => c.codePointAt(0) > 0x24f && !/\p{M}/u.test(c)))], + }; +} +const FACTICES = metriquesFactices(); +const JEUX = [ + ['Noto Sans', NOTO], + ['chasse fixe', FACTICES], +]; + +const EVENEMENT = 'Souper du Club des Merles'; +const DATE = '9 octobre 2026'; +const HORODATAGE = 'Placement du 2026-10-09 19:00:00'; +const PIED = [`${EVENEMENT} ${TIRET} ${DATE}`, HORODATAGE]; +const PIED_REDUIT = [DATE, HORODATAGE]; +const tour = (r, table) => `Tour ${r} ${TIRET} table ${table}`; +const tours = (n) => Array.from({ length: n }, (_, i) => tour(i + 1, i + 3)); + +/** Les textes d'un badge, complétés des valeurs par défaut. */ +const textes = (champs = {}) => ({ + nom: 'Iris Ombrelle', + lignes: tours(4), + appartenance: 'Club des Merles', + pied: PIED, + piedReduit: PIED_REDUIT, + ...champs, +}); + +const capParPt = (metriques) => metriques.hauteurs(1).capitale; +const corpsNomMax = (metriques) => constante('HAUTEUR_CAPITALE_NOM_MAX_MM') / capParPt(metriques); +const corpsNomPlancher = (metriques) => constante('HAUTEUR_CAPITALE_NOM_PLANCHER_MM') / capParPt(metriques); + +// Des noms composés dont un mot, trait d'union compris, déborde la ligne aux +// grands corps : un repli à trois lignes coupé dans ce mot y tient, à un corps +// où le repli de mots entiers ne tient pas encore. +const NOMS_COMPOSES = [ + 'Ondelie-Mauve Lacasse-Pervenche de la Grisaille', + 'Théa-Iris Lamontagne-Desrochers de Grandpré', + 'Iris Lacasse-Pervenche-Grisaille de Théo', +]; + +// Des lignes de tour sous sièges attribués, numérotées comme tours(n). +const toursAuxSieges = (n) => tours(n).map((ligne, i) => `${ligne}, siège ${i + 1}`); + +const blocsNom = (page) => page.blocs.filter((b) => b.gras); +const textesDe = (page) => page.blocs.map((b) => b.texte); + +// La boîte d'un bloc : sa largeur mesurée, de l'ascendante au-dessus de la +// ligne de base à la descendante au-dessous. +function boite(bloc, metriques) { + const { ascendante, descendante } = metriques.hauteurs(bloc.corps); + return { + gauche: bloc.x, + droite: bloc.x + metriques.largeur(bloc.texte, bloc.corps, bloc.gras), + haut: bloc.y - ascendante, + bas: bloc.y + descendante, + }; +} + +/** Les blocs qui sortent du cadre utile, [] quand tout y tient. */ +function horsDuCadre(page, metriques) { + return page.blocs.filter((bloc) => { + const b = boite(bloc, metriques); + return ( + b.gauche < -TOLERANCE || + b.haut < -TOLERANCE || + b.droite > CADRE.largeur + TOLERANCE || + b.bas > CADRE.hauteur + TOLERANCE + ); + }); +} + +/** Les paires de blocs dont les boîtes se recouvrent. */ +function chevauchements(page, metriques) { + const boites = page.blocs.map((bloc) => boite(bloc, metriques)); + const paires = []; + for (let i = 0; i < boites.length; i += 1) { + for (let j = i + 1; j < boites.length; j += 1) { + const a = boites[i]; + const b = boites[j]; + const disjointes = + a.droite <= b.gauche + TOLERANCE || + b.droite <= a.gauche + TOLERANCE || + a.bas <= b.haut + TOLERANCE || + b.bas <= a.haut + TOLERANCE; + if (!disjointes) paires.push([page.blocs[i].texte, page.blocs[j].texte]); + } + } + return paires; +} + +// Vrai quand les lignes, rejointes chacune par une espace ou par rien, +// redonnent le texte : rien n'est retiré, ajouté ni abrégé. +function rejoint(lignes, texte) { + let reste = texte; + for (const [rang, ligne] of lignes.entries()) { + if (rang > 0 && reste.startsWith(' ')) reste = reste.slice(1); + if (!reste.startsWith(ligne)) return false; + reste = reste.slice(ligne.length); + } + return reste === ''; +} + +const repete = (motif, longueur) => motif.repeat(Math.ceil(longueur / motif.length)).slice(0, longueur).trim(); + +describe('mise en page : les métriques de la police embarquée', () => { + test('la largeur est celle de pdf-lib, convertie de points en millimètres, par graisse', () => { + for (const texte of ['WWWWWWWWWWWW', 'Ôjgq Æ W', 'Iris Ombrelle']) { + assert.equal(NOTO.largeur(texte, 20, false), regulierPdf.widthOfTextAtSize(texte, 20) * MM_PAR_PT); + assert.equal(NOTO.largeur(texte, 20, true), grasPdf.widthOfTextAtSize(texte, 20) * MM_PAR_PT); + } + assert.ok(NOTO.largeur('WWWW', 20, true) > NOTO.largeur('WWWW', 20, false)); + }); + + test('les hauteurs se lisent dans la police, proportionnelles au corps, la descendante positive', () => { + const fonte = regulierPdf.embedder.font; + const h = NOTO.hauteurs(10); + assert.ok(Math.abs(h.capitale - (fonte.capHeight / fonte.unitsPerEm) * 10 * MM_PAR_PT) < 1e-12); + assert.ok(Math.abs(h.ascendante - (fonte.ascent / fonte.unitsPerEm) * 10 * MM_PAR_PT) < 1e-12); + assert.ok(Math.abs(h.descendante - (-fonte.descent / fonte.unitsPerEm) * 10 * MM_PAR_PT) < 1e-12); + assert.ok(h.descendante > 0 && h.capitale < h.ascendante); + assert.ok(Math.abs(NOTO.hauteurs(20).capitale - 2 * h.capitale) < 1e-12); + }); + + test('les absents : chaque caractère sans glyphe, une fois, dans l’ordre de première rencontre', () => { + assert.deepEqual(NOTO.absents('Ôjgq Æ W — Benoît Œuvre fi e\u{301}'), []); + assert.deepEqual(NOTO.absents('Iris \u{674E} Ombrelle \u{6731}\u{674E}'), ['\u{674E}', '\u{6731}']); + assert.deepEqual(NOTO.absents('\u{1D49C}da'), ['\u{1D49C}']); + }); + + test('une police sans fontkit (police standard de pdf-lib) est refusée', async () => { + const standard = await document.embedFont(StandardFonts.Helvetica); + assert.throws(() => metriquesDe({ regulier: standard, gras: grasPdf }), TypeError); + assert.throws(() => metriquesDe({ regulier: regulierPdf, gras: undefined }), TypeError); + }); +}); + +describe('mise en page : le nom (§ 11.4, § 11.5)', () => { + for (const [jeu, metriques] of JEUX) { + test(`${jeu} : un nom court tient sur une ligne en gras, au corps dont la capitale vaut le maximum, centré`, () => { + const page = mettreEnPage(textes(), metriques); + const [ligne, ...autres] = blocsNom(page); + assert.deepEqual(autres, []); + assert.equal(ligne.texte, 'Iris Ombrelle'); + assert.ok(Math.abs(ligne.corps - corpsNomMax(metriques)) < 1e-9); + const largeur = metriques.largeur(ligne.texte, ligne.corps, true); + assert.ok(Math.abs(ligne.x - (CADRE.largeur - largeur) / 2) < 1e-9); + assert.deepEqual(horsDuCadre(page, metriques), []); + }); + + test(`${jeu} : « WWWWWWWWWWWW » et « ÆÆÆÆÆ » tiennent dans le cadre, le premier au corps réduit par paliers d’un point`, () => { + for (const nom of ['WWWWWWWWWWWW', 'ÆÆÆÆÆ', 'ÆÆÆÆÆÆÆÆÆÆÆÆ']) { + const page = mettreEnPage(textes({ nom }), metriques); + assert.deepEqual(textesDe(page).slice(0, 1), [nom], nom); + assert.deepEqual(horsDuCadre(page, metriques), [], nom); + assert.deepEqual(chevauchements(page, metriques), [], nom); + } + const [w] = blocsNom(mettreEnPage(textes({ nom: 'WWWWWWWWWWWW' }), metriques)); + assert.ok(w.corps < corpsNomMax(metriques) && w.corps >= corpsNomPlancher(metriques)); + const paliers = corpsNomMax(metriques) - w.corps; + assert.ok(Math.abs(paliers - Math.round(paliers)) < 1e-9, `paliers : ${paliers}`); + // Un point de plus, et la ligne déborderait : le corps est le plus grand qui tient. + assert.ok(metriques.largeur('WWWWWWWWWWWW', w.corps + 1, true) > CADRE.largeur); + }); + + test(`${jeu} : le corps se réduit jusqu’au plancher avant que le nom ne se replie`, () => { + // Un nom qui tient au plancher sur une ligne n'en prend jamais deux. + const nom = 'Ondine Lacasse-Pervenche'; + assert.ok(metriques.largeur(nom, corpsNomPlancher(metriques), true) <= CADRE.largeur); + assert.ok(metriques.largeur(nom, corpsNomMax(metriques), true) > CADRE.largeur); + assert.equal(blocsNom(mettreEnPage(textes({ nom }), metriques)).length, 1); + }); + + test(`${jeu} : un nom de 200 caractères se replie, jamais abrégé ; les lignes rejointes redonnent le nom`, () => { + const nom = repete('Iris Ombrelle-Pervenche ', 200); + const page = mettreEnPage(textes({ nom }), metriques); + const lignes = blocsNom(page).map((b) => b.texte); + assert.ok(lignes.length > 3, `lignes : ${lignes.length}`); + assert.ok(rejoint(lignes, nom), JSON.stringify(lignes)); + assert.ok(lignes.every((l) => !l.includes(MARQUE) && l === l.trim())); + assert.ok(blocsNom(page).every((b) => Math.abs(b.corps - corpsNomPlancher(metriques)) < 1e-9)); + assert.deepEqual(horsDuCadre(page, metriques), []); + assert.deepEqual(chevauchements(page, metriques), []); + }); + + test(`${jeu} : un nom de 2 000 caractères est refusé, PLANCHE_NOM_TROP_LONG`, () => { + const nom = repete('Iris Ombrelle-Pervenche ', 2000); + assert.deepEqual(mettreEnPage(textes({ nom }), metriques), { + refus: { code: 'PLANCHE_NOM_TROP_LONG', details: {} }, + }); + }); + + test(`${jeu} : sans espace, le nom se coupe entre deux points de code, jamais avant une marque combinante`, () => { + const nom = 'Pe\u{301}'.repeat(60); + const page = mettreEnPage(textes({ nom }), metriques); + const lignes = blocsNom(page).map((b) => b.texte); + assert.ok(lignes.length >= 2); + assert.ok(rejoint(lignes, nom)); + assert.ok(lignes.every((l) => !/^\p{M}/u.test(l)), JSON.stringify(lignes.map((l) => l.slice(0, 3)))); + assert.deepEqual(horsDuCadre(page, metriques), []); + }); + + test(`${jeu} : un nom de plusieurs mots se coupe aux espaces, chaque ligne faite de mots entiers`, () => { + const nom = 'Ondine Lacasse Théo Pervenche Iris Ombrelle Grisaille'; + const lignes = blocsNom(mettreEnPage(textes({ nom }), metriques)).map((b) => b.texte); + assert.ok(lignes.length >= 2); + const mots = new Set(nom.split(' ')); + assert.ok(lignes.every((l) => l.split(' ').every((m) => mots.has(m))), JSON.stringify(lignes)); + assert.equal(lignes.join(' '), nom); + }); + + test(`${jeu} : la coupe dans un mot ne vient qu’à défaut ; un repli de mots entiers à un corps plus petit passe avant`, () => { + for (const nom of NOMS_COMPOSES) { + const lignes = blocsNom(mettreEnPage(textes({ nom }), metriques)).map((b) => b.texte); + // Rejointes par une espace, des lignes coupées aux espaces redonnent le + // nom ; une coupe dans un mot y ajouterait une espace. + assert.ok(lignes.length >= 2 && lignes.length <= 3, JSON.stringify(lignes)); + assert.equal(lignes.join(' '), nom, JSON.stringify(lignes)); + } + }); + } + + test('une marque combinante qui avance reste sur la ligne de sa lettre, même quand elle la ferait déborder', () => { + // Sous la police embarquée, une marque combinante n'avance pas : elle ne + // fait jamais déborder une ligne, et la coupe ne tombe devant elle que si + // le repli la sépare de sa lettre. Une marque qui avance éprouve la coupe + // elle-même. + const metriques = metriquesFactices({ marque: 1 }); + const nom = 'Pe\u{301}'.repeat(60); + const lignes = blocsNom(mettreEnPage(textes({ nom }), metriques)).map((b) => b.texte); + assert.ok(lignes.length >= 2); + assert.ok(rejoint(lignes, nom)); + assert.ok(lignes.every((l) => !/^\p{M}/u.test(l)), JSON.stringify(lignes.map((l) => l.slice(0, 3)))); + }); + + test('le plancher et le départ se calculent de la hauteur de capitale lue dans les métriques', () => { + // Cent caractères dépassent trois lignes sous les deux capitales, et + // tiennent au plancher ; deux cents tiennent sous Noto Sans. + const moyen = repete('Iris Ombrelle-Pervenche ', 100); + const long = repete('Iris Ombrelle-Pervenche ', 200); + for (const capitale of [0.7, 0.5]) { + const metriques = metriquesFactices({ capitale }); + const court = blocsNom(mettreEnPage(textes({ nom: 'Iris' }), metriques))[0].corps; + assert.ok(Math.abs(court - constante('HAUTEUR_CAPITALE_NOM_MAX_MM') / (capitale * MM_PAR_PT)) < 1e-9); + const replie = blocsNom(mettreEnPage(textes({ nom: moyen }), metriques))[0].corps; + assert.ok(Math.abs(replie - constante('HAUTEUR_CAPITALE_NOM_PLANCHER_MM') / (capitale * MM_PAR_PT)) < 1e-9); + } + // Sur Noto Sans, la capitale du corps plancher vaut le plancher du registre. + const [ligne] = blocsNom(mettreEnPage(textes({ nom: long }), NOTO)); + assert.ok(Math.abs(NOTO.hauteurs(ligne.corps).capitale - constante('HAUTEUR_CAPITALE_NOM_PLANCHER_MM')) < 1e-9); + }); +}); + +describe('mise en page : l’ordre de sacrifice (§ 11.5, décision 8)', () => { + for (const [jeu, metriques] of JEUX) { + test(`${jeu} : au-delà de trois lignes, le nom de l’événement part d’abord, puis l’appartenance ; date et horodatage restent`, () => { + const vus = { complet: 0, sansEvenement: 0, sansAppartenance: 0, refus: 0 }; + for (let longueur = 10; longueur <= 600; longueur += 10) { + const nom = repete('Iris Ombrelle-Pervenche ', longueur); + const page = mettreEnPage(textes({ nom, lignes: tours(5) }), metriques); + if ('refus' in page) { + assert.equal(page.refus.code, 'PLANCHE_NOM_TROP_LONG', `${longueur}`); + vus.refus += 1; + continue; + } + const tout = textesDe(page); + const evenement = tout.some((t) => t.includes(EVENEMENT)); + const appartenance = tout.includes('Club des Merles'); + assert.ok(tout.includes(HORODATAGE), `horodatage, ${longueur}`); + assert.ok(tout.some((t) => t.includes(DATE)), `date, ${longueur}`); + assert.equal(tours(5).filter((t) => tout.includes(t)).length, 5, `tours, ${longueur}`); + // L'appartenance ne part jamais avant le nom de l'événement. + assert.ok(evenement ? appartenance : true, `ordre, ${longueur}`); + // Rien ne part tant que le nom tient en trois lignes. + if (blocsNom(page).length <= 3) assert.ok(evenement && appartenance, `trois lignes, ${longueur}`); + else assert.ok(!evenement, `au-delà de trois lignes, ${longueur}`); + if (evenement) vus.complet += 1; + else if (appartenance) vus.sansEvenement += 1; + else vus.sansAppartenance += 1; + assert.deepEqual(horsDuCadre(page, metriques), [], `${longueur}`); + assert.deepEqual(chevauchements(page, metriques), [], `${longueur}`); + } + for (const [cas, nombre] of Object.entries(vus)) assert.ok(nombre > 0, `${cas} : ${JSON.stringify(vus)}`); + }); + + test(`${jeu} : sous un nom de trois lignes au plus, le nom descend jusqu’au plancher avant que rien ne parte`, () => { + // Un nom d'événement long occupe plusieurs lignes du pied : son départ + // libère de quoi poser une ligne de tour de plus. + const pied = [`${repete('Grand souper annuel du Club des Merles et de la Grisaille ', 160)} ${TIRET} ${DATE}`, HORODATAGE]; + const vus = { descendu: 0, sansEvenement: 0, sansAppartenance: 0, refus: 0 }; + for (const nom of NOMS_COMPOSES) { + const depart = blocsNom(mettreEnPage(textes({ nom }), metriques))[0].corps; + let precedent = { corps: depart, refus: false }; + for (let n = 12; n <= 30; n += 1) { + const lignes = toursAuxSieges(n); + const cas = `${nom}, ${n} tours`; + const page = mettreEnPage(textes({ nom, lignes, pied }), metriques); + if ('refus' in page) { + assert.deepEqual(page.refus, { code: 'PLANCHE_TROP_DE_TOURS', details: { tours: n } }, cas); + vus.refus += 1; + precedent = { corps: precedent.corps, refus: true }; + continue; + } + assert.ok(!precedent.refus, `${cas} : accepté après un refus`); + const tout = textesDe(page); + const evenement = tout.some((t) => t.startsWith('Grand souper')); + const appartenance = tout.includes('Club des Merles'); + const nomBlocs = blocsNom(page); + const corps = nomBlocs[0].corps; + const auPlancher = Math.abs(corps - corpsNomPlancher(metriques)) < 1e-9; + assert.ok(nomBlocs.length <= 3, `${cas} : ${nomBlocs.length} lignes de nom`); + assert.ok(lignes.every((l) => tout.includes(l)), `${cas} : tours`); + assert.ok(tout.join(' ').includes(DATE) && tout.includes(HORODATAGE), `${cas} : date, horodatage`); + // Plus de tours, jamais un nom plus grand. + assert.ok(corps <= precedent.corps + 1e-9, `${cas} : ${corps} après ${precedent.corps}`); + // Un texte ne part que sous le nom au plancher, l'événement d'abord. + assert.ok(evenement || auPlancher, `${cas} : l’événement part sous un nom de ${corps} pt`); + assert.ok(appartenance || !evenement, `${cas} : l’appartenance part avant l’événement`); + if (evenement && corps < depart - 1e-9 && !auPlancher) vus.descendu += 1; + // Un nom qui partait au-dessus du plancher y est descendu avant que + // l'événement ne parte. + if (!evenement && depart > corpsNomPlancher(metriques) + 1e-9) { + vus[appartenance ? 'sansEvenement' : 'sansAppartenance'] += 1; + } + assert.deepEqual(horsDuCadre(page, metriques), [], cas); + assert.deepEqual(chevauchements(page, metriques), [], cas); + precedent = { corps, refus: false }; + } + } + for (const [cas, nombre] of Object.entries(vus)) assert.ok(nombre > 0, `${cas} : ${JSON.stringify(vus)}`); + }); + } +}); + +describe('mise en page : l’appartenance (§ 11.5)', () => { + for (const [jeu, metriques] of JEUX) { + test(`${jeu} : 200 caractères tiennent sur une ligne finie par « … », au plus long préfixe qui tient`, () => { + const appartenance = repete('Club des Merles et de la Grisaille ', 200); + const page = mettreEnPage(textes({ appartenance }), metriques); + const blocs = page.blocs.filter((b) => b.texte.endsWith(MARQUE)); + assert.equal(blocs.length, 1); + const [bloc] = blocs; + assert.equal(bloc.corps, constante('CORPS_APPARTENANCE_PT')); + assert.equal(bloc.gras, false); + const prefixe = bloc.texte.slice(0, -MARQUE.length); + assert.ok(prefixe.length > 0 && appartenance.startsWith(prefixe) && prefixe === prefixe.trimEnd()); + assert.ok(metriques.largeur(bloc.texte, bloc.corps, false) <= CADRE.largeur); + // Le préfixe suivant, espaces finales retirées, déborderait. + let fin = prefixe.length + 1; + while (appartenance.slice(0, fin).trimEnd() === prefixe) fin += 1; + const suivant = appartenance.slice(0, fin).trimEnd(); + assert.ok(metriques.largeur(`${suivant}${MARQUE}`, bloc.corps, false) > CADRE.largeur, suivant); + assert.deepEqual(horsDuCadre(page, metriques), []); + }); + + test(`${jeu} : sans appartenance, aucun bloc, et les tours remontent de la hauteur de sa ligne`, () => { + const avec = mettreEnPage(textes(), metriques); + const sans = mettreEnPage(textes({ appartenance: null }), metriques); + assert.equal(sans.blocs.length, avec.blocs.length - 1); + assert.ok(!textesDe(sans).includes('Club des Merles')); + const premierTour = (page) => page.blocs.find((b) => b.texte === tour(1, 3)); + const { ascendante, descendante } = metriques.hauteurs(constante('CORPS_APPARTENANCE_PT')); + assert.ok(Math.abs(premierTour(avec).y - premierTour(sans).y - (ascendante + descendante)) < 1e-9); + assert.ok(textesDe(sans).every((t) => t !== TIRET && t.trim() !== '')); + }); + } + test('le plus long préfixe qui tient avec la marque est gardé, même quand le caractère suivant, large, déborde', () => { + // Sous la chasse fixe, le préfixe tient avec la marque, et la capitale + // large qui le suit fait déborder le préfixe seul : la recherche du plus + // long préfixe ne s'arrête pas un caractère trop tôt. + const corps = constante('CORPS_APPARTENANCE_PT'); + const prefixe = 'Grisaille Grisaille Grisaille Grisaille Ondin'; + assert.ok(FACTICES.largeur(`${prefixe}${MARQUE}`, corps, false) <= CADRE.largeur); + assert.ok(FACTICES.largeur(`${prefixe}W`, corps, false) > CADRE.largeur); + const page = mettreEnPage(textes({ appartenance: `${prefixe}WWW du Club` }), FACTICES); + assert.ok(textesDe(page).includes(`${prefixe}${MARQUE}`), JSON.stringify(textesDe(page))); + }); +}); + +describe('mise en page : les lignes de tour (§ 11.5)', () => { + for (const [jeu, metriques] of JEUX) { + test(`${jeu} : de 2 à 6 tours sur une colonne, chaque ligne entière, dans l’ordre`, () => { + for (let n = 2; n <= 6; n += 1) { + const page = mettreEnPage(textes({ lignes: tours(n) }), metriques); + assert.equal(page.colonnesTours, 1, `${n}`); + const lignes = page.blocs.filter((b) => b.texte.startsWith('Tour ')); + assert.deepEqual(lignes.map((b) => b.texte), tours(n)); + assert.ok(lignes.every((b) => b.corps === constante('CORPS_TOURS_PT') && b.x === lignes[0].x)); + assert.ok(lignes.every((b, i) => i === 0 || b.y > lignes[i - 1].y)); + } + }); + + test(`${jeu} : douze tours tiennent sur une colonne sous un nom court, sur deux sous un nom qui prend la place`, () => { + // Le passage à deux colonnes se mesure sur le gabarit, pas sur un + // nombre de tours : sous un nom d'une ligne, douze tours tiennent au + // corps des tours. + const court = mettreEnPage(textes({ lignes: tours(12) }), metriques); + assert.equal(court.colonnesTours, 1); + assert.ok(court.blocs.filter((b) => b.texte.startsWith('Tour ')).every((b) => b.corps === constante('CORPS_TOURS_PT'))); + + // Le premier nom, par longueurs croissantes, qui renvoie les douze + // tours sur deux colonnes. + let page = null; + for (let longueur = 10; longueur <= 600 && page === null; longueur += 10) { + const essai = mettreEnPage(textes({ nom: repete('Iris Ombrelle-Pervenche ', longueur), lignes: tours(12) }), metriques); + if (!('refus' in essai) && essai.colonnesTours === 2) page = essai; + } + assert.notEqual(page, null); + const lignes = page.blocs.filter((b) => b.texte.startsWith('Tour ')); + assert.deepEqual(lignes.map((b) => b.texte), tours(12)); + // Colonne de gauche : les six premiers, de haut en bas ; puis la droite, + // rangée par rangée à la même ligne de base. + assert.ok(lignes.slice(0, 6).every((b) => b.x === lignes[0].x)); + assert.ok(lignes.slice(6).every((b) => b.x === lignes[6].x && b.x > lignes[0].x)); + assert.ok(lignes.slice(6).every((b, i) => b.y === lignes[i].y)); + assert.ok(lignes.slice(1, 6).every((b, i) => b.y > lignes[i].y)); + assert.deepEqual(horsDuCadre(page, metriques), []); + assert.deepEqual(chevauchements(page, metriques), []); + // Une colonne au corps plancher n'y tenait pas : entre le haut du + // premier tour et le haut du pied, douze lignes au plancher débordent. + const corps = lignes[0].corps; + const { ascendante, descendante } = metriques.hauteurs(constante('CORPS_TOURS_PLANCHER_PT')); + const hautTours = lignes[0].y - metriques.hauteurs(corps).ascendante; + const premierPied = page.blocs.find((b) => b.corps === constante('CORPS_PIED_PT') && !b.texte.startsWith('Tour ')); + const hautPied = premierPied.y - metriques.hauteurs(constante('CORPS_PIED_PT')).ascendante; + assert.ok(hautTours + 12 * (ascendante + descendante) > hautPied); + }); + + test(`${jeu} : avant deux colonnes, le corps des tours descend jusqu’au plancher`, () => { + const nom = 'Ondine Lacasse Théo Pervenche Iris Ombrelle Grisaille Merles'; + const resultats = []; + for (let n = 1; n <= 30; n += 1) { + const page = mettreEnPage(textes({ nom, lignes: tours(n) }), metriques); + if ('refus' in page) break; + const corps = page.blocs.find((b) => b.texte === tour(1, 3)).corps; + resultats.push({ n, colonnes: page.colonnesTours, corps }); + } + const premiereDouble = resultats.find((r) => r.colonnes === 2); + assert.ok(premiereDouble !== undefined); + const derniereSimple = resultats.filter((r) => r.colonnes === 1).at(-1); + assert.equal(derniereSimple.n, premiereDouble.n - 1); + assert.equal(derniereSimple.corps, constante('CORPS_TOURS_PLANCHER_PT')); + assert.ok(resultats.some((r) => r.colonnes === 1 && r.corps < constante('CORPS_TOURS_PT') && r.corps > constante('CORPS_TOURS_PLANCHER_PT'))); + }); + + test(`${jeu} : cent tours sont refusés, PLANCHE_TROP_DE_TOURS nomme cent`, () => { + assert.deepEqual(mettreEnPage(textes({ lignes: tours(100) }), metriques), { + refus: { code: 'PLANCHE_TROP_DE_TOURS', details: { tours: 100 } }, + }); + }); + + test(`${jeu} : une ligne trop large pour la colonne s’abrège à la fin, au corps plancher ; les autres restent entières`, () => { + const titre = `${tour(2, 5)} ${TIRET} ${repete('Animatrice de la table d’honneur ', 120)}`; + const lignes = [tour(1, 3), titre, tour(3, 7)]; + const page = mettreEnPage(textes({ lignes }), metriques); + assert.equal(page.colonnesTours, 1); + const blocs = page.blocs.filter((b) => b.texte.startsWith('Tour ')); + assert.equal(blocs.length, 3); + assert.equal(blocs[0].texte, tour(1, 3)); + assert.equal(blocs[2].texte, tour(3, 7)); + assert.ok(blocs[1].texte.endsWith(MARQUE) && blocs[1].texte.startsWith(tour(2, 5))); + assert.ok(blocs.every((b) => b.corps === constante('CORPS_TOURS_PLANCHER_PT'))); + assert.deepEqual(horsDuCadre(page, metriques), []); + }); + + test(`${jeu} : à deux colonnes, au corps plancher, un titre trop large pour sa colonne s’abrège ; table et siège restent`, () => { + // Le premier nombre de tours que ce nom renvoie sur deux colonnes ; la + // première ligne y reçoit un titre plus large qu'une demi-colonne. + const nom = 'Ondine Lacasse Théo Pervenche Iris Ombrelle Grisaille Merles'; + let n = 1; + while (n < 60 && mettreEnPage(textes({ nom, lignes: tours(n) }), metriques).colonnesTours !== 2) n += 1; + assert.ok(n < 60); + const lignes = tours(n); + lignes[0] = `${tour(1, 3)} ${TIRET} ${repete('Animatrice de la table d’honneur ', 60)}`; + const page = mettreEnPage(textes({ nom, lignes }), metriques); + assert.equal(page.colonnesTours, 2, `${n} : ${JSON.stringify(page.refus)}`); + const blocs = page.blocs.filter((b) => b.texte.startsWith('Tour ')); + assert.equal(blocs.length, n); + const [titre, ...autres] = blocs; + assert.ok(titre.texte.endsWith(MARQUE) && titre.texte.startsWith(tour(1, 3)), titre.texte); + assert.ok(lignes[0].startsWith(titre.texte.slice(0, -MARQUE.length)), titre.texte); + // La colonne de droite, étroite, laisse au titre plus d'une demi-colonne. + const plancher = constante('CORPS_TOURS_PLANCHER_PT'); + const demi = (CADRE.largeur - plancher * MM_PAR_PT) / 2; + assert.ok(metriques.largeur(titre.texte, plancher, false) > demi, titre.texte); + assert.deepEqual(autres.map((b) => b.texte), lignes.slice(1)); + assert.ok(blocs.every((b) => b.corps === constante('CORPS_TOURS_PLANCHER_PT'))); + assert.deepEqual(horsDuCadre(page, metriques), []); + assert.deepEqual(chevauchements(page, metriques), []); + }); + + test(`${jeu} : une ligne sans titre ne s’abrège jamais, à une colonne comme à deux ; le badge est refusé plutôt`, () => { + // Une colonne : une table dont le numéro déborde le cadre au plancher, + // sans titre, puis suivie d'un titre. + const large = tour(1, '9'.repeat(80)); + assert.ok(metriques.largeur(large, constante('CORPS_TOURS_PLANCHER_PT'), false) > CADRE.largeur); + for (const ligne of [large, `${large} ${TIRET} Animatrice`]) { + assert.deepEqual(mettreEnPage(textes({ lignes: [ligne] }), metriques), { + refus: { code: 'PLANCHE_TROP_DE_TOURS', details: { tours: 1 } }, + }); + } + // Deux colonnes : des lignes aux sièges plus larges qu'une demi-colonne, + // la première titrée. Quelle que soit la disposition retenue, chaque + // ligne sans titre paraît entière, et la titrée garde sa place. + const nom = 'Ondine Lacasse Théo Pervenche Iris Ombrelle Grisaille Merles'; + const lignes = Array.from({ length: 20 }, (_, i) => `${tour(i + 1, i + 30)}, siège ${i + 10}`); + const demi = (CADRE.largeur - constante('CORPS_TOURS_PLANCHER_PT') * MM_PAR_PT) / 2; + assert.ok(lignes.slice(-10).every((l) => metriques.largeur(l, constante('CORPS_TOURS_PLANCHER_PT'), false) > demi)); + const place = lignes[0]; + lignes[0] = `${place} ${TIRET} Animatrice de la table d’honneur`; + const page = mettreEnPage(textes({ nom, lignes }), metriques); + if ('refus' in page) { + assert.equal(page.refus.code, 'PLANCHE_TROP_DE_TOURS'); + return; + } + const tout = textesDe(page); + assert.ok(lignes.slice(1).every((l) => tout.includes(l)), JSON.stringify(tout.filter((t) => t.endsWith(MARQUE)))); + assert.ok(tout.some((t) => t.startsWith(place))); + }); + } + + test('un titre abrégé garde au moins une grappe : sa place suivie du séparateur et de la marque seuls ne passent pas pour un titre', () => { + // Sous la chasse fixe, la place, le séparateur et la marque tiennent au + // corps plancher, mais pas une grappe de titre de plus : le titre ne + // peut pas s'abréger, et la ligne ne se rend pas réduite à sa place. + const plancher = constante('CORPS_TOURS_PLANCHER_PT'); + let chiffres = 1; + const place = () => tour(1, '9'.repeat(chiffres)); + while ( + chiffres < 400 && + !(FACTICES.largeur(`${place()} ${TIRET}${MARQUE}`, plancher, false) <= CADRE.largeur && + FACTICES.largeur(`${place()} ${TIRET} A${MARQUE}`, plancher, false) > CADRE.largeur) + ) chiffres += 1; + assert.ok(chiffres < 400); + const page = mettreEnPage(textes({ lignes: [`${place()} ${TIRET} Animatrice`] }), FACTICES); + if ('refus' in page) return; + const abregees = textesDe(page).filter((t) => t.startsWith('Tour ') && t.endsWith(MARQUE)); + assert.ok( + abregees.every((t) => t.slice(0, -MARQUE.length).trimEnd().length > `${place()} ${TIRET}`.length), + JSON.stringify(abregees), + ); + }); + + test('Noto Sans : la plus large ligne sans titre tient entière sur une colonne au corps plancher', () => { + // L'abrègement d'une ligne en colonne unique ne mange alors que le titre. + const ligne = `Toute la soirée ${TIRET} table 9999, siège 9999`; + assert.ok(NOTO.largeur(ligne, constante('CORPS_TOURS_PLANCHER_PT'), false) <= CADRE.largeur); + }); +}); + +describe('mise en page : le pied (§ 11.4)', () => { + for (const [jeu, metriques] of JEUX) { + test(`${jeu} : le pied s’ancre au bas du cadre, au corps du pied, chaque ligne présente`, () => { + const page = mettreEnPage(textes(), metriques); + const pied = page.blocs.slice(-2); + assert.deepEqual(pied.map((b) => b.texte), PIED); + assert.ok(pied.every((b) => b.corps === constante('CORPS_PIED_PT') && !b.gras)); + const { descendante } = metriques.hauteurs(constante('CORPS_PIED_PT')); + assert.ok(Math.abs(pied[1].y + descendante - CADRE.hauteur) < 1e-9); + }); + + test(`${jeu} : un nom d’événement très long se replie dans le pied sans perdre la date`, () => { + const evenement = repete('Grand souper annuel du Club des Merles ', 200); + const pied = [`${evenement} ${TIRET} ${DATE}`, HORODATAGE]; + const page = mettreEnPage(textes({ pied }), metriques); + const lignes = page.blocs.filter((b) => b.corps === constante('CORPS_PIED_PT')).map((b) => b.texte); + assert.ok(lignes.length > 2); + assert.ok(rejoint(lignes.slice(0, -1), pied[0])); + assert.equal(lignes.at(-1), HORODATAGE); + assert.deepEqual(horsDuCadre(page, metriques), []); + assert.deepEqual(chevauchements(page, metriques), []); + }); + } +}); + +describe('mise en page : le corps du nom plafonne les autres corps (§ 11.4)', () => { + test('sous une grande capitale, le nom au plancher plafonne le corps des tours, de l’appartenance et du pied', () => { + // Capitale de 1,2 cadratin : le plancher du nom tombe sous le corps + // plancher des tours et sous celui de l'appartenance. + const metriques = metriquesFactices({ capitale: 1.2 }); + const plancher = corpsNomPlancher(metriques); + assert.ok(plancher < constante('CORPS_TOURS_PLANCHER_PT') && plancher < constante('CORPS_APPARTENANCE_PT'), `${plancher}`); + const nom = repete('Iris Ombrelle-Pervenche ', 150); + for (const n of [4, 12]) { + const page = mettreEnPage(textes({ nom, lignes: tours(n) }), metriques); + const nomBlocs = blocsNom(page); + assert.ok(Math.abs(nomBlocs[0].corps - plancher) < 1e-9, `${n} : ${nomBlocs[0].corps}`); + const autres = page.blocs.filter((b) => !b.gras); + assert.ok(autres.some((b) => b.texte === tour(1, 3)) && autres.some((b) => b.texte === 'Club des Merles'), `${n}`); + assert.ok(autres.every((b) => b.corps <= plancher + 1e-9), JSON.stringify(autres.map((b) => [b.texte, b.corps]))); + assert.deepEqual(horsDuCadre(page, metriques), [], `${n}`); + assert.deepEqual(chevauchements(page, metriques), [], `${n}`); + } + }); +}); + +describe('mise en page : propriétés sur des noms engendrés', () => { + // Une boucle écrite à la main sur des cas tirés d'une graine (§ 14.12 : la + // bibliothèque de propriétés ne sert qu'aux endroits que le spec nomme). + // L'alphabet latin étendu : capitales et minuscules, Latin-1, Latin étendu + // A, quelques marques combinantes, l'espace, le trait d'union, + // l'apostrophe ; douze lettres pour une marque et trois séparateurs. + const LATIN = [ + ...'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', + ...Array.from({ length: 0xff - 0xc0 + 1 }, (_, i) => String.fromCodePoint(0xc0 + i)).filter((c) => !'×÷'.includes(c)), + ...Array.from({ length: 0x17f - 0x100 + 1 }, (_, i) => String.fromCodePoint(0x100 + i)), + ]; + const MARQUES = ['\u{300}', '\u{301}', '\u{308}', '\u{327}']; + const SEPARATEURS = [' ', '-', '\u{2019}']; + + // Un générateur congruentiel de Park et Miller : la même graine rend la + // même suite de cas, d'une machine à l'autre. + function tirage(graine) { + let etat = graine; + const suivant = () => { + etat = (etat * 48271) % 2147483647; + return etat / 2147483647; + }; + const entier = (min, max) => min + Math.floor(suivant() * (max - min + 1)); + const parmi = (liste) => liste[entier(0, liste.length - 1)]; + const caractere = () => { + const de = entier(1, 16); + return de <= 12 ? parmi(LATIN) : de === 13 ? parmi(MARQUES) : parmi(SEPARATEURS); + }; + const texte = (maxLength) => { + for (;;) { + const t = Array.from({ length: entier(1, maxLength) }, caractere).join(''); + if (t.trim() !== '') return t; + } + }; + return () => ({ + nom: texte(suivant() < 0.5 ? 30 : 260), + appartenance: suivant() < 0.25 ? null : texte(120), + tours: entier(1, 30), + sieges: suivant() < 0.5, + }); + } + const GRAINE = 4242; + // Chaque texte nouveau coûte une mise en forme fontkit sous Noto Sans : la + // série surveillée lui accorde moins de tirages qu'aux métriques factices. + const TIRAGES = new Map([ + [NOTO, 100], + [FACTICES, 300], + ]); + + for (const [jeu, metriques] of JEUX) { + test(`${jeu} : tout bloc dans le cadre, aucun chevauchement, le nom au plus grand corps, rien de perdu`, () => { + const bilan = { acceptes: 0, auDela: 0, deuxColonnes: 0, abregees: 0, refus: 0 }; + const tirer = tirage(GRAINE); + for (let rang = 0; rang < TIRAGES.get(metriques); rang += 1) { + const { nom, appartenance, tours: n, sieges } = tirer(); + const cas = JSON.stringify({ rang, nom, appartenance, tours: n, sieges }); + const nomPropre = nom.trim().replace(/ +/g, ' '); + const lignes = Array.from({ length: n }, (_, i) => `${tour(i + 1, i + 2)}${sieges ? `, siège ${i + 1}` : ''}`); + const page = mettreEnPage(textes({ nom: nomPropre, appartenance, lignes }), metriques); + if ('refus' in page) { + bilan.refus += 1; + assert.ok(['PLANCHE_TROP_DE_TOURS', 'PLANCHE_NOM_TROP_LONG'].includes(page.refus.code), cas); + if (page.refus.code === 'PLANCHE_TROP_DE_TOURS') assert.deepEqual(page.refus.details, { tours: n }, cas); + continue; + } + bilan.acceptes += 1; + assert.deepEqual(horsDuCadre(page, metriques), [], cas); + assert.deepEqual(chevauchements(page, metriques), [], cas); + const nomBlocs = blocsNom(page); + assert.ok(rejoint(nomBlocs.map((b) => b.texte), nomPropre), cas); + const corpsNom = nomBlocs[0].corps; + assert.ok(page.blocs.every((b) => b.corps <= corpsNom), cas); + assert.ok(nomBlocs.every((b) => b.corps === corpsNom), cas); + // Aucune ligne de tour ne manque ni ne s'abrège. + const tout = textesDe(page); + assert.ok(lignes.every((l) => tout.includes(l)), cas); + assert.ok(tout.includes(HORODATAGE), cas); + if (nomBlocs.length > 3) bilan.auDela += 1; + if (page.colonnesTours === 2) bilan.deuxColonnes += 1; + if (tout.some((t) => t.endsWith(MARQUE))) bilan.abregees += 1; + } + // Un tirage qui n'atteint pas les cas difficiles rend la propriété + // vraie sans rien éprouver : chaque famille a son plancher. + assert.ok(bilan.acceptes >= (TIRAGES.get(metriques) * 2) / 3, JSON.stringify(bilan)); + assert.ok(bilan.auDela >= 10, JSON.stringify(bilan)); + assert.ok(bilan.deuxColonnes >= 10, JSON.stringify(bilan)); + assert.ok(bilan.abregees >= 10, JSON.stringify(bilan)); + }); + } +});