// © 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 } } }; }