gestion_table_tournante_libre/src/pdf/mise_en_page.js

436 lines
20 KiB
JavaScript
Raw Normal View History

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