[ADD] pdf: badge sheet production with control page and draft mark

The badge sheet must print at true scale and read back exactly what the
model says. Page 1 is the control sheet: badge size, paper, counts,
namesakes, missing glyphs and a 100 mm ruler; it continues on extra control
pages when a long reprint overflows. One page per sheet follows, with cut
lines, embedded Noto Sans subsets and a ToUnicode map, no metadata, so the
same model yields the same bytes. A draft carries BROUILLON in outline on
every page; the word comes from the caller, never from src/pdf.
Checked: red first, text read back through pdfjs; 3541 node and 101 long
tests green from the index alone.

--- FR ---

[ADD] pdf : production de la planche, page de contrôle, brouillon

La planche de badges doit s'imprimer à l'échelle et se relire exactement
comme le modèle le dit. La page 1 est la feuille de contrôle : taille du
badge, papier, comptes, homonymes, glyphes absents et règle de 100 mm ; elle
continue sur des pages de contrôle quand une longue réédition déborde. Suit
une page par planche, avec traits de coupe, sous-ensembles de Noto Sans et
table ToUnicode, sans métadonnées : le même modèle rend les mêmes octets.
Un brouillon porte BROUILLON au trait sur chaque page ; le mot vient de
l'appelant, jamais de src/pdf.
Vérifié : rouge d'abord, texte relu par pdfjs ; 3541 node et 101 longues
vertes depuis l'index seul.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-09 15:26:53 -04:00
parent 8b7c732399
commit 8fb80324e7
5 changed files with 1554 additions and 0 deletions

442
src/pdf/planche.js Normal file
View file

@ -0,0 +1,442 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La production de la planche de badges (§ 11.1 à § 11.4) : le seul PDF du
// logiciel, dessiné en mémoire par pdf-lib depuis le modèle, jamais depuis
// le DOM. Les deux entrées partent des octets des polices : la couche
// application ne voit jamais un objet de pdf-lib. Ce module ne compose
// aucun mot : les textes du badge, les lignes de la feuille de contrôle et
// le mot du filigrane lui arrivent composés ; il n'écrit de lui-même que les
// nombres de la graduation.
//
// Le document : page 1, la feuille de contrôle, qui ne se découpe pas ;
// puis une page par planche, dans l'ordre d'imposer. Les cotes se posent en
// millimètres, origine en haut à gauche de la feuille, et se convertissent
// en points au seul moment d'écrire : aucune matrice d'échelle (cm) n'entre
// dans le flux, ni CropBox, ni UserUnit — une transformation posée dans le
// flux ne se verrait sur aucune boîte de page, et la planche sortirait à la
// mauvaise taille sans qu'un contrôle des boîtes ne le dise. Un texte tourné
// l'est par sa matrice de texte (Tm), qui ne touche pas les chemins. Le
// texte s'écrit par ses opérateurs (BT, Tf, Tm, Tj, ET), tel que la police
// l'encode, comme la mise en page l'a mesuré : chaque police entre une fois
// dans les ressources de chaque page.
//
// Les octets ne dépendent que des entrées : pdf-lib tire les suffixes de ses
// noms de ressources d'un générateur à graine fixe, créé avec le document ;
// la date du document vient de l'horodatage du placement, jamais d'une
// horloge ; ni Producer, ni Creator, ni ModDate, ni /ID.
import fontkit from '@pdf-lib/fontkit';
import {
PDFDocument,
PDFName,
PDFString,
TextRenderingMode,
beginText,
endText,
grayscale,
popGraphicsState,
pushGraphicsState,
setFontAndSize,
setLineWidth,
setStrokingGrayscaleColor,
setTextMatrix,
setTextRenderingMode,
showText,
} from 'pdf-lib';
import { constante } from '../geometrie/constantes.js';
import { BADGE, PAPIERS } from '../geometrie/papiers.js';
import { cadreUtile, cellules, filets, imposer } from './imposition.js';
import { mettreEnPage, metriquesDe } from './mise_en_page.js';
/**
* Ce que la production lit du modèle de la planche, ModelePlanche de
* src/application/planche.js, que src/pdf n'importe pas.
*
* @typedef {Object} ModelePlanche
* @property {Array<{textes: {nom: string, lignes: string[], appartenance: string|null,
* pied: string[], piedReduit: string[]}}>} badges dans l'ordre de la pile
* @property {string|null} horodatage l'horodatage du placement ; null : inconnu
* @property {boolean} brouillon
*/
/** Le millimètre en points typographiques. */
const PT_PAR_MM = 72 / 25.4;
/** Les impositions que la planche connaît (§ 11.6). */
const IMPOSITIONS = new Set(['feuille', 'pile']);
// Le trait des filets de coupe : fin, d'une encre neutre, continu (§ 11.3).
const FILET = Object.freeze({ epaisseurPt: 0.25, gris: 0.5 });
// Le contour du filigrane : un texte au trait, gris, qui laisse lire ce
// qu'il recouvre (§ 11.7).
const CONTOUR_FILIGRANE = Object.freeze({ epaisseurPt: 0.6, gris: 0.55 });
// La feuille de contrôle. La règle : 100 mm, un repère tous les 10 mm, plus
// long tous les 50 mm, son chiffre au-dessus ; elle se pose au coin haut
// gauche de la page, en deçà de la marge non imprimable (MARGE_PAGE_MM), et
// se trace en noir, le trait de la mesure. Les lignes de contrôle suivent, au
// corps du texte courant, repliées dans la largeur utile.
const REGLE = Object.freeze({
longueurMm: 100,
pasMm: 10,
reperesLongsMm: 50,
hauteurRepereMm: 3,
hauteurRepereLongMm: 5,
// De la marge haute à la ligne de la règle : la place des chiffres et des
// repères longs.
descenteMm: 10,
corpsChiffresPt: 8,
// Du haut d'un repère long à la ligne de base de son chiffre.
ecartChiffreMm: 1,
epaisseurPt: 0.5,
// De la ligne de la règle à la première ligne de contrôle, et au cadre du
// filigrane : la règle reste hors de tout texte.
degagementMm: 8,
});
const CORPS_CONTROLE_PT = 11;
// La forme d'un horodatage, celle qu'estHorodatage de src/stockage/journal.js
// tient — date, heure à la seconde, décalage local —, relue ici parce que
// src/pdf n'importe pas le stockage. Ses groupes composent la date PDF.
const HORODATAGE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})([+-])(\d{2}):(\d{2})$/;
// Une grappe : un point de code suivi de ses marques combinantes ; une
// ligne de contrôle trop longue se coupe entre deux grappes.
const GRAPPE = /\P{M}\p{M}*|\p{M}+/gu;
/**
* La date PDF d'un horodatage, décalage local gardé :
* « 2026-10-09T19:00:00-04:00 » → « D:20261009190000-04'00' ».
*
* @param {string} horodatage
* @returns {string}
* @throws {TypeError} un texte d'une autre forme
*/
function datePdf(horodatage) {
const morceaux = typeof horodatage === 'string' ? HORODATAGE.exec(horodatage) : null;
if (morceaux === null) throw new TypeError(`horodatage de la forme AAAA-MM-JJTHH:MM:SS±HH:MM attendu, reçu ${JSON.stringify(horodatage)}`);
const [, annee, mois, jour, heure, minute, seconde, signe, hDecalage, mDecalage] = morceaux;
return `D:${annee}${mois}${jour}${heure}${minute}${seconde}${signe}${hDecalage}'${mDecalage}'`;
}
// Les entrées communes, contrôlées : un modèle qui porte des badges, deux
// polices en octets.
function controlerModeleEtPolices(modele, polices) {
if (!Array.isArray(modele?.badges)) throw new TypeError('modele : un modèle de planche attendu');
if (!(polices?.regulier instanceof Uint8Array) || !(polices?.gras instanceof Uint8Array)) {
throw new TypeError('polices : {regulier, gras} en Uint8Array attendus');
}
}
// Un document neuf, les deux graisses embarquées en sous-ensemble, chacune
// avec sa ToUnicode, et leurs métriques. updateMetadata faux : pdf-lib
// n'écrit ni Producer, ni Creator, ni dates.
async function documentAvecPolices(polices) {
const pdf = await PDFDocument.create({ updateMetadata: false });
pdf.registerFontkit(fontkit);
const regulier = await pdf.embedFont(polices.regulier, { subset: true });
const gras = await pdf.embedFont(polices.gras, { subset: true });
return { pdf, regulier, gras, metriques: metriquesDe({ regulier, gras }) };
}
// Les textes d'un badge, dans l'ordre où ses absents se nomment.
const textesDuBadge = ({ textes }) => [
textes.nom,
...(textes.appartenance === null ? [] : [textes.appartenance]),
...textes.lignes,
...textes.pied,
...textes.piedReduit,
];
/**
* L'analyse d'une planche avant sa production (§ 11.5, décision 12) : la
* mise en page de chaque badge, dans l'ordre du modèle, et les caractères
* que la police embarquée ne dessine pas. Rien ne se produit.
*
* @param {Object} entrees
* @param {ModelePlanche} entrees.modele
* @param {{regulier: Uint8Array, gras: Uint8Array}} entrees.polices
* @returns {Promise<{refus: null | {code: string, details: object, rang: number}, absents: string[]}>}
* refus : le premier badge que la mise en page refuse, rang étant son
* indice dans modele.badges ; src/pdf ne connaît pas la personne, et
* l'appelant complète les détails. absents : les caractères sans glyphe
* de tous les textes des badges, une fois chacun, dans l'ordre de
* première rencontre.
* @throws {TypeError} un modèle sans badges, des polices qui ne sont pas des octets
*/
export async function analyserPlanche({ modele, polices }) {
controlerModeleEtPolices(modele, polices);
const { metriques } = await documentAvecPolices(polices);
let refus = null;
const absents = [];
const vus = new Set();
modele.badges.forEach((badge, rang) => {
if (refus === null) {
const page = mettreEnPage(badge.textes, metriques);
if (page.refus !== undefined) refus = { ...page.refus, rang };
}
for (const caractere of metriques.absents(textesDuBadge(badge).join(''))) {
if (vus.has(caractere)) continue;
vus.add(caractere);
absents.push(caractere);
}
});
return { refus, absents };
}
// Un repère : la position, en points du repère PDF (origine en bas à
// gauche), d'un point donné en millimètres, et la direction (ux, uy),
// unitaire, où court l'axe des x de ce repère, celle d'un texte droit.
// Le repère d'un badge sur sa page, le badge droit — origine au coin bas
// gauche, y vers le haut. Debout, le badge occupe sa cellule tel quel. À
// plat, il tourne de 90° dans le sens trigonométrique, son haut à gauche :
// son coin bas gauche tombe au coin bas droit de la cellule, son axe des x
// monte, son axe des y va vers la gauche.
function repereDuBadge(cellule, hauteurPage) {
const bas = (hauteurPage - cellule.y - cellule.hauteur) * PT_PAR_MM;
if (!cellule.aPlat) {
const gauche = cellule.x * PT_PAR_MM;
return { point: (x, y) => [gauche + x * PT_PAR_MM, bas + y * PT_PAR_MM], ux: 1, uy: 0 };
}
const droite = (cellule.x + cellule.largeur) * PT_PAR_MM;
return { point: (x, y) => [droite - y * PT_PAR_MM, bas + x * PT_PAR_MM], ux: 0, uy: 1 };
}
// Le repère d'une page, depuis son coin haut gauche, y vers le bas.
const repereDePage = (hauteurPage) => ({ point: (x, y) => [x * PT_PAR_MM, (hauteurPage - y) * PT_PAR_MM], ux: 1, uy: 0 });
// Le repère droit d'une page, depuis son coin bas gauche, y vers le haut :
// celui du PDF.
const REPERE_DROIT = Object.freeze({ point: (x, y) => [x * PT_PAR_MM, y * PT_PAR_MM], ux: 1, uy: 0 });
// La clé de ressource d'une police sur une page, créée à sa première
// demande, la même ensuite.
const CLES = new WeakMap();
function cleDePolice(page, police) {
if (!CLES.has(page)) CLES.set(page, new Map());
const cles = CLES.get(page);
if (!cles.has(police)) cles.set(police, page.node.newFontDictionary(police.name, police.ref));
return cles.get(police);
}
// Un texte posé par ses opérateurs : sa ligne de base part de (X, Y) points
// et court selon (ux, uy). Le mode de rendu 1 le trace au trait, gris, sans
// remplissage ; le mode par défaut le remplit de l'encre par défaut, noire.
function ecrire(page, texte, { police, corps, X, Y, ux, uy, contour = null }) {
page.pushOperators(
pushGraphicsState(),
...(contour === null
? []
: [setStrokingGrayscaleColor(contour.gris), setLineWidth(contour.epaisseurPt), setTextRenderingMode(TextRenderingMode.Outline)]),
beginText(),
setFontAndSize(cleDePolice(page, police), corps),
setTextMatrix(ux, uy, -uy, ux, X, Y),
showText(police.encodeText(texte)),
endText(),
popGraphicsState(),
);
}
/**
* Le filigrane d'un brouillon, inscrit dans un cadre : le mot au trait
* (mode de rendu 1), dans la couche de texte, tourné selon la diagonale du
* cadre qui monte vers la droite, au plus grand corps dont l'englobant —
* avance du mot sur la hauteur d'ascendante à descendante — tient dans le
* cadre, centré sur lui.
*
* @param {import('pdf-lib').PDFPage} page
* @param {{x: number, y: number, largeur: number, hauteur: number}} cadre
* mm, dans le repère droit du repère reçu : origine en bas à gauche
* @param {{point: Function, ux: number, uy: number}} repere
*/
function poserFiligrane(page, { mot, police, metriques }, cadre, repere) {
const phi = Math.atan2(cadre.hauteur, cadre.largeur);
const [cos, sin] = [Math.cos(phi), Math.sin(phi)];
const avanceParPt = metriques.largeur(mot, 1, true);
const { ascendante, descendante } = metriques.hauteurs(1);
const hauteurParPt = ascendante + descendante;
const corps = Math.min(
cadre.largeur / (avanceParPt * cos + hauteurParPt * sin),
cadre.hauteur / (avanceParPt * sin + hauteurParPt * cos),
);
// L'origine du mot : le centre du cadre, moins le centre de l'englobant
// du mot droit, tourné de phi.
const [mx, my] = [(avanceParPt * corps) / 2, ((ascendante - descendante) * corps) / 2];
const ox = cadre.x + cadre.largeur / 2 - (mx * cos - my * sin);
const oy = cadre.y + cadre.hauteur / 2 - (mx * sin + my * cos);
const [X, Y] = repere.point(ox, oy);
// La direction du mot : celle du repère, tournée de phi.
const [ux, uy] = [repere.ux * cos - repere.uy * sin, repere.ux * sin + repere.uy * cos];
ecrire(page, mot, { police, corps, X, Y, ux, uy, contour: CONTOUR_FILIGRANE });
}
// Un texte droit dans son repère, sa ligne de base en (x, y) mm.
function poserTexte(page, texte, { x, y, corps, police }, repere) {
const [X, Y] = repere.point(x, y);
ecrire(page, texte, { police, corps, X, Y, ux: repere.ux, uy: repere.uy });
}
// Un segment en millimètres, repère haut gauche de la page.
function poserSegment(page, { x1, y1, x2, y2 }, hauteurPage, { epaisseurPt, gris }) {
const repere = repereDePage(hauteurPage);
const [X1, Y1] = repere.point(x1, y1);
const [X2, Y2] = repere.point(x2, y2);
page.drawLine({ start: { x: X1, y: Y1 }, end: { x: X2, y: Y2 }, thickness: epaisseurPt, color: grayscale(gris) });
}
// Un texte replié dans une largeur, en mm : coupé aux espaces, et entre deux
// grappes pour un mot plus large que la ligne. Jamais tronqué.
function replier(texte, largeurMax, mesurer) {
const lignes = [];
let courante = '';
for (const mot of texte.split(' ').filter((m) => m !== '')) {
const avecMot = courante === '' ? mot : `${courante} ${mot}`;
if (mesurer(avecMot) <= largeurMax) {
courante = avecMot;
continue;
}
if (courante !== '') lignes.push(courante);
courante = '';
for (const grappe of mot.match(GRAPPE)) {
if (courante !== '' && mesurer(courante + grappe) > largeurMax) {
lignes.push(courante);
courante = '';
}
courante += grappe;
}
}
if (courante !== '' || lignes.length === 0) lignes.push(courante);
return lignes;
}
// La règle graduée de la feuille de contrôle, en haut à gauche de la page,
// chaque chiffre centré sur son repère : elle commence à la demi-largeur du
// « 0 » de la marge, qui ne déborde pas dans la marge. Rend l'ordonnée de
// sa ligne, en mm depuis le haut.
function poserRegle(page, { regulier, metriques }, hauteurPage) {
const marge = constante('MARGE_PAGE_MM');
const largeurChiffres = (chiffres) => metriques.largeur(chiffres, REGLE.corpsChiffresPt, false);
const origine = marge + largeurChiffres('0') / 2;
const ligne = marge + REGLE.descenteMm;
const trait = { epaisseurPt: REGLE.epaisseurPt, gris: 0 };
poserSegment(page, { x1: origine, y1: ligne, x2: origine + REGLE.longueurMm, y2: ligne }, hauteurPage, trait);
for (let mm = 0; mm <= REGLE.longueurMm; mm += REGLE.pasMm) {
const long = mm % REGLE.reperesLongsMm === 0;
const x = origine + mm;
const haut = ligne - (long ? REGLE.hauteurRepereLongMm : REGLE.hauteurRepereMm);
poserSegment(page, { x1: x, y1: haut, x2: x, y2: ligne }, hauteurPage, trait);
if (long) {
const chiffres = String(mm);
const position = { x: x - largeurChiffres(chiffres) / 2, y: haut - REGLE.ecartChiffreMm };
poserTexte(page, chiffres, { ...position, corps: REGLE.corpsChiffresPt, police: regulier }, repereDePage(hauteurPage));
}
}
return ligne;
}
// La feuille de contrôle (§ 11.3) : la règle, puis les lignes de contrôle
// repliées dans la largeur utile, et le filigrane d'un brouillon dans le
// cadre sous la règle. Des lignes qui débordent la page continuent sur une
// page suivante, sans règle, qui porte elle aussi le filigrane : la feuille
// ne se découpe pas, ses pages non plus.
function poserControle(pdf, { controle, papier, polices, metriques, filigrane }) {
const { largeur: W, hauteur: H } = PAPIERS[papier];
const marge = constante('MARGE_PAGE_MM');
const largeurUtile = W - 2 * marge;
const { ascendante, descendante } = metriques.hauteurs(CORPS_CONTROLE_PT);
const mesurer = (texte) => metriques.largeur(texte, CORPS_CONTROLE_PT, false);
const lignes = controle.flatMap((ligne) => replier(ligne, largeurUtile, mesurer));
const nouvellePage = (avecRegle) => {
const page = pdf.addPage([W * PT_PAR_MM, H * PT_PAR_MM]);
const haut = avecRegle ? poserRegle(page, { ...polices, metriques }, H) + REGLE.degagementMm : marge;
if (filigrane !== null) {
// Le cadre du filigrane, en mm dans le repère droit de la page : sous
// la règle, dans les marges.
const cadre = { x: marge, y: marge, largeur: largeurUtile, hauteur: H - marge - haut };
poserFiligrane(page, { ...filigrane, metriques }, cadre, REPERE_DROIT);
}
return { page, curseur: haut };
};
let { page, curseur } = nouvellePage(true);
for (const ligne of lignes) {
if (curseur + ascendante + descendante > H - marge) ({ page, curseur } = nouvellePage(false));
poserTexte(page, ligne, { x: marge, y: curseur + ascendante, corps: CORPS_CONTROLE_PT, police: polices.regulier }, repereDePage(H));
curseur += ascendante + descendante;
}
}
/**
* La planche de badges, en octets PDF (§ 11.1 à § 11.4).
*
* Page 1 : la feuille de contrôle — la règle de 100 mm repérée tous les
* 10 mm, les lignes de controle, et le filigrane d'un brouillon, une fois,
* hors de la règle ; aucun filet de coupe. Puis une page par planche : les
* filets des cellules occupées, et dans chacune, le badge que la mise en
* page pose dans son cadre utile, et le filigrane d'un brouillon, une fois,
* inscrit dans ce cadre. Une cellule vide ne porte rien.
*
* La boîte de média est celle du papier ; ni CropBox ni UserUnit. /Info ne
* porte que CreationDate, la date de l'horodatage du placement, décalage
* local gardé, et n'existe pas quand l'horodatage est inconnu.
*
* @param {Object} entrees
* @param {ModelePlanche} entrees.modele
* @param {import('../geometrie/papiers.js').Papier} entrees.papier
* @param {'feuille'|'pile'} entrees.imposition
* @param {{regulier: Uint8Array, gras: Uint8Array}} entrees.polices
* @param {string[]} entrees.controle les lignes de textesControle
* @param {string} [entrees.filigrane] le mot du filigrane, composé par
* libelle ; exigé quand modele.brouillon est vrai, ignoré sinon
* @returns {Promise<Uint8Array>}
* @throws {TypeError} une entrée hors de son domaine, un horodatage d'une
* autre forme, un brouillon sans le mot de son filigrane
* @throws {RangeError} un badge que la mise en page refuse : analyserPlanche
* le dit avant de produire
*/
export async function produirePlanche({ modele, papier, imposition, polices, controle, filigrane = null }) {
controlerModeleEtPolices(modele, polices);
if (typeof papier !== 'string' || !Object.hasOwn(PAPIERS, papier)) throw new TypeError(`papier inconnu : ${JSON.stringify(papier)}`);
if (!IMPOSITIONS.has(imposition)) throw new TypeError(`imposition inconnue : ${JSON.stringify(imposition)}`);
if (!Array.isArray(controle) || !controle.every((ligne) => typeof ligne === 'string')) {
throw new TypeError('controle : les lignes de la feuille de contrôle, des chaînes, attendues');
}
if (typeof modele.brouillon !== 'boolean') throw new TypeError('modele.brouillon : booléen attendu');
if (modele.brouillon && (typeof filigrane !== 'string' || filigrane.trim() === '')) {
throw new TypeError("filigrane : le mot du filigrane d'un brouillon attendu");
}
const date = modele.horodatage === null ? null : datePdf(modele.horodatage);
const { pdf, regulier, gras, metriques } = await documentAvecPolices(polices);
const mises = modele.badges.map((badge, rang) => {
const mise = mettreEnPage(badge.textes, metriques);
if (mise.refus !== undefined) throw new RangeError(`badge ${rang} refusé par la mise en page : ${mise.refus.code}`);
return mise;
});
if (date !== null) pdf.getInfoDict().set(PDFName.of('CreationDate'), PDFString.of(date));
const marque = modele.brouillon ? { mot: filigrane, police: gras } : null;
poserControle(pdf, { controle, papier, polices: { regulier, gras }, metriques, filigrane: marque });
const { largeur: W, hauteur: H } = PAPIERS[papier];
const lesCellules = cellules(papier);
const cadreBadge = cadreUtile({ x: 0, y: 0, largeur: BADGE.largeur, hauteur: BADGE.hauteur });
// Le cadre utile dans le repère droit du badge, y vers le haut.
const cadreDroit = { ...cadreBadge, y: BADGE.hauteur - cadreBadge.y - cadreBadge.hauteur };
for (const planche of imposer(modele.badges.length, papier, imposition)) {
const page = pdf.addPage([W * PT_PAR_MM, H * PT_PAR_MM]);
for (const segment of filets(papier, planche.map((indice) => indice !== null))) poserSegment(page, segment, H, FILET);
planche.forEach((indice, rang) => {
if (indice === null) return;
const repere = repereDuBadge(lesCellules[rang], H);
for (const { texte, corps, gras: enGras, x, y } of mises[indice].blocs) {
const position = { x: cadreBadge.x + x, y: BADGE.hauteur - (cadreBadge.y + y) };
poserTexte(page, texte, { ...position, corps, police: enGras ? gras : regulier }, repere);
}
if (marque !== null) poserFiligrane(page, { ...marque, metriques }, cadreDroit, repere);
});
}
return pdf.save();
}

View file

@ -0,0 +1,186 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La production de la planche aux comptes qui séparent troncature et
// arrondi par excès (§ 11.2, § 14.2), sur les deux papiers et les deux
// impositions, relue dans l'ordre des noms de la couche de texte ; la
// planche Lettre pleine, ses badges à plat dans leur zone de silence ; le
// témoin de 257 badges ; une feuille de contrôle plus longue qu'une page.
// Chaque production se chronomètre : la durée de la planche entière est un
// coût que le rapport de la tâche relève, jamais une donnée écrite.
import assert from 'node:assert/strict';
import { performance } from 'node:perf_hooks';
import { lirePdf } from '../../test/pdf.js';
import { lirePolices } from '../../test/polices.js';
import { describe, test } from '../../test/lanceur.js';
import { libelle } from '../application/libelles.js';
import { textesControle } from '../application/planche.js';
import { constante } from '../geometrie/constantes.js';
import { PAPIERS } from '../geometrie/papiers.js';
import { VERSION } from '../version.genere.js';
import { cadreUtile, cellules, filets, imposer, nombrePlanches } from './imposition.js';
import { produirePlanche } from './planche.js';
const ZONE = constante('ZONE_SILENCE_MM');
const TOLERANCE = 1e-6;
const FILIGRANE = libelle('planche.brouillon');
const polices = lirePolices();
const EVENEMENT = { nom: 'Souper du Club des Merles', date: '2026-10-09' };
const HORODATAGE = '2026-10-09T19:00:00-04:00';
/** Le nom du badge de rang i : « Témoin 001 »… */
const nomTemoin = (i) => `Témoin ${String(i + 1).padStart(3, '0')}`;
/** Un modèle de n badges écrit à la main, chacun à trois tours. */
function modele(n, { brouillon = false, reedition = false } = {}) {
const ligneHorodatage = libelle('planche.pied.horodatage', { horodatage: HORODATAGE });
const badges = Array.from({ length: n }, (_, i) => {
const lignes = [1, 2, 3].map((tour) => ({ tour, table: ((i + tour) % 30) + 1, siege: null, titre: null }));
return {
participant: i + 1,
nom: nomTemoin(i),
appartenance: i % 3 === 0 ? 'Club des Merles' : null,
lignes,
identifiant: null,
textes: {
nom: nomTemoin(i),
lignes: lignes.map((ligne) => libelle('planche.tour', ligne)),
appartenance: i % 3 === 0 ? 'Club des Merles' : null,
pied: [libelle('planche.pied.evenement', EVENEMENT), ligneHorodatage],
piedReduit: [libelle('planche.pied.date', EVENEMENT), ligneHorodatage],
},
};
});
return {
badges,
evenement: EVENEMENT,
horodatage: HORODATAGE,
brouillon,
comptes: { participants: n, exclus: 0, produits: n },
homonymes: [],
reedition: reedition ? { ids: badges.map(({ participant }) => participant) } : null,
version: VERSION.affichee,
};
}
// Les durées relevées, imprimées par la dernière épreuve.
const durees = [];
/** Une production chronométrée, relue. */
async function produireEtLire(m, papier, imposition) {
const controle = textesControle(m, { papier, imposition, planches: nombrePlanches(m.badges.length, papier) });
const debut = performance.now();
const octets = await produirePlanche({ modele: m, papier, imposition, polices, controle, filigrane: FILIGRANE });
durees.push({ cas: `${papier} ${imposition} n = ${m.badges.length}`, ms: performance.now() - debut });
return lirePdf(octets);
}
const centre = ({ x, y, l, h }) => [x + l / 2, y + h / 2];
const dans = ([cx, cy], { x, y, largeur, hauteur }) => cx > x && cx < x + largeur && cy > y && cy < y + hauteur;
// Les noms lus dans chaque cellule de chaque page de planche ; null pour une
// cellule sans nom.
function nomsLus(pages, papier) {
const toutes = cellules(papier);
return pages.map((page) =>
toutes.map((cellule) => {
const lus = page.textes.filter((t) => t.texte.startsWith('Témoin ') && dans(centre(t), cellule)).map((t) => t.texte);
assert.ok(lus.length <= 1, lus.join(', '));
return lus[0] ?? null;
}),
);
}
// La distance d'une boîte de texte à un segment parallèle à un axe.
function distance({ x, y, l, h }, { x1, y1, x2, y2 }) {
const dx = Math.max(0, Math.min(x1, x2) - (x + l), x - Math.max(x1, x2));
const dy = Math.max(0, Math.min(y1, y2) - (y + h), y - Math.max(y1, y2));
return Math.hypot(dx, dy);
}
const PAGINATION = [
...[1, 4, 5, 259, 261].map((n) => ({ papier: 'A4', n })),
...[1, 2, 3, 257].map((n) => ({ papier: 'Lettre', n })),
];
describe('produirePlanche : la pagination aux bords (§ 11.2)', () => {
for (const { papier, n } of PAGINATION) {
for (const imposition of ['feuille', 'pile']) {
test(`${papier}, ${imposition}, n = ${n} : une page de contrôle, puis les planches, les noms dans l'ordre d'imposer`, async () => {
const lu = await produireEtLire(modele(n), papier, imposition);
assert.equal(lu.pages.length, 1 + nombrePlanches(n, papier));
const attendu = imposer(n, papier, imposition).map((planche) => planche.map((i) => (i === null ? null : nomTemoin(i))));
assert.deepEqual(nomsLus(lu.pages.slice(1), papier), attendu);
});
}
}
});
describe('produirePlanche : la géométrie du Lettre et le témoin (§ 11.2, § 11.3)', () => {
test('Lettre pleine, brouillon : badges à plat, cinq axes, chaque texte dans le cadre utile de sa cellule et hors de la zone de silence', async () => {
const lu = await produireEtLire(modele(2, { brouillon: true }), 'Lettre', 'feuille');
const [planche] = lu.pages.slice(1);
assert.equal(filets('Lettre', [true, true]).length, 5);
assert.equal(planche.segments.length, 5);
const lesCellules = cellules('Lettre');
const { largeur: W, hauteur: H } = PAPIERS.Lettre;
const axes = [
...filets('Lettre', [true, true]),
{ x1: 0, y1: 0, x2: W, y2: 0 },
{ x1: 0, y1: H, x2: W, y2: H },
{ x1: 0, y1: 0, x2: 0, y2: H },
{ x1: W, y1: 0, x2: W, y2: H },
];
for (const fragment of planche.textes) {
const cellule = lesCellules.find((c) => dans(centre(fragment), c));
assert.ok(cellule !== undefined && cellule.aPlat, JSON.stringify(fragment));
const cadre = cadreUtile(cellule);
const dedans =
fragment.x >= cadre.x - TOLERANCE &&
fragment.y >= cadre.y - TOLERANCE &&
fragment.x + fragment.l <= cadre.x + cadre.largeur + TOLERANCE &&
fragment.y + fragment.h <= cadre.y + cadre.hauteur + TOLERANCE;
assert.ok(dedans, `« ${fragment.texte} » ${JSON.stringify(fragment)} hors de ${JSON.stringify(cadre)}`);
assert.ok(Math.min(...axes.map((axe) => distance(fragment, axe))) >= ZONE - TOLERANCE, fragment.texte);
}
// À plat, un texte droit monte : sa boîte est plus haute que large.
for (const nom of planche.textes.filter(({ texte }) => texte.startsWith('Témoin '))) assert.ok(nom.h > nom.l);
assert.equal(planche.textes.filter(({ texte }) => texte === FILIGRANE).length, 2);
});
test('témoin de 257 badges : 66 pages en A4, 130 en Lettre, feuille de contrôle comprise', async () => {
const temoin = modele(257);
assert.equal((await produireEtLire(temoin, 'A4', 'feuille')).pages.length, 66);
assert.equal((await produireEtLire(temoin, 'Lettre', 'pile')).pages.length, 130);
});
});
describe('produirePlanche : une feuille de contrôle plus longue qu\u{2019}une page (§ 11.3)', () => {
test('les lignes qui débordent continuent sur une page de contrôle sans règle ni filet, avant les planches', async () => {
const m = modele(150, { brouillon: true, reedition: true });
const controle = textesControle(m, { papier: 'A4', imposition: 'feuille', planches: nombrePlanches(150, 'A4') });
const lu = await produireEtLire(m, 'A4', 'feuille');
const planches = nombrePlanches(150, 'A4');
const pagesControle = lu.pages.length - planches;
assert.ok(pagesControle >= 2, `${pagesControle} page(s) de contrôle`);
// Le filigrane se reconnaît à sa boîte : tourné, il est plus haut
// qu'aucune ligne de contrôle.
const estFiligrane = ({ texte, h }) => texte === FILIGRANE && h > 20;
const controleLu = lu.pages
.slice(0, pagesControle)
.flatMap(({ textes }) => textes.filter((t) => !estFiligrane(t)).map(({ texte }) => texte));
// Chaque ligne une fois, dans l'ordre, après les chiffres de la règle.
assert.deepEqual(controleLu, ['0', '50', '100', ...controle]);
for (const page of lu.pages.slice(0, pagesControle)) assert.equal(page.textes.filter(estFiligrane).length, 1);
for (const page of lu.pages.slice(1, pagesControle)) assert.deepEqual(page.segments, []);
const attendu = imposer(150, 'A4', 'feuille').map((planche) => planche.map((i) => (i === null ? null : nomTemoin(i))));
assert.deepEqual(nomsLus(lu.pages.slice(pagesControle), 'A4'), attendu);
});
});
describe('produirePlanche : les durées', () => {
test('chaque production relevée a sa durée', () => {
assert.ok(durees.length > 0);
console.log(durees.map(({ cas, ms }) => `${cas} : ${Math.round(ms)} ms`).join('\n'));
});
});

421
src/pdf/planche.test.js Normal file
View file

@ -0,0 +1,421 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La production de la planche (§ 11.1 à § 11.4), éprouvée sur le fichier
// produit, relu comme un lecteur réel le lit (test/pdf.js) : les boîtes de
// page, /Info et /ID, les filets mesurés sous la matrice courante, l'ordre
// des noms dans la couche de texte, la zone de silence, les caractères
// extraits un à un, le filigrane, la feuille de contrôle et sa graduation,
// des octets identiques d'un export à l'autre.
//
// La série surveillée borne le coût (§ 14.4) : trois productions sur de
// petits modèles écrits à la main, que toutes les épreuves du fichier
// lisent — A4, cinq badges, feuille par feuille, brouillon, horodatage
// connu, produite deux fois ; Lettre, trois badges, en pile, retenu,
// horodatage inconnu. Les lignes de contrôle viennent de textesControle, le
// mot du filigrane de libelle, comme la séance les passera.
import assert from 'node:assert/strict';
import { PDFDocument, PDFName } from 'pdf-lib';
import { lirePdf } from '../../test/pdf.js';
import { lirePolices } from '../../test/polices.js';
import { describe, test } from '../../test/lanceur.js';
import { libelle } from '../application/libelles.js';
import { textesControle } from '../application/planche.js';
import { constante } from '../geometrie/constantes.js';
import { PAPIERS } from '../geometrie/papiers.js';
import { VERSION } from '../version.genere.js';
import { cellules, filets, imposer, nombrePlanches } from './imposition.js';
import { analyserPlanche, produirePlanche } from './planche.js';
const TOLERANCE = 1e-6;
const ZONE = constante('ZONE_SILENCE_MM');
const MARGE = constante('MARGE_PAGE_MM');
const FILIGRANE = libelle('planche.brouillon');
const TIRET = '\u{2014}';
const polices = lirePolices();
const EVENEMENT = { nom: 'Souper du Club des Merles', date: '2026-10-09' };
const HORODATAGE = '2026-10-09T19:00:00-04:00';
/** Un badge écrit à la main : ses textes tels que BA1 les compose. */
function badge(participant, nom, { appartenance = null, tours = 2, horodatage = HORODATAGE } = {}) {
const lignes = Array.from({ length: tours }, (_, i) => ({ tour: i + 1, table: participant + i, siege: null, titre: null }));
const ligneHorodatage = horodatage === null ? libelle('planche.pied.horodatageInconnu') : libelle('planche.pied.horodatage', { horodatage });
return {
participant,
nom,
appartenance,
lignes,
identifiant: null,
textes: {
nom,
lignes: lignes.map((ligne) => libelle('planche.tour', ligne)),
appartenance,
pied: [libelle('planche.pied.evenement', EVENEMENT), ligneHorodatage],
piedReduit: [libelle('planche.pied.date', EVENEMENT), ligneHorodatage],
},
};
}
/** Un modèle de planche écrit à la main. */
function modele(badges, { horodatage = HORODATAGE, brouillon = false, exclus = 0 } = {}) {
return {
badges,
evenement: EVENEMENT,
horodatage,
brouillon,
comptes: { participants: badges.length + exclus, exclus, produits: badges.length },
homonymes: [],
reedition: null,
version: VERSION.affichee,
};
}
const NOMS_A4 = ['Iris Ombrelle', 'Ôjgq Æ Grisaille', 'Théo Pervenche', 'Ondine Lacasse', 'Merle Bleuet'];
const MODELE_A4 = modele(
NOMS_A4.map((nom, i) => badge(i + 1, nom, { appartenance: i % 2 === 0 ? 'Club des Merles' : null })),
{ brouillon: true, exclus: 1 },
);
const NOMS_LETTRE = ['Aube Sarcelle', 'Côme Pervenche', 'Ysée Lacasse'];
const MODELE_LETTRE = modele(
NOMS_LETTRE.map((nom, i) => badge(i + 10, nom, { horodatage: null, tours: 3 })),
{ horodatage: null },
);
const entrees = (m, papier, imposition) => ({
modele: m,
papier,
imposition,
polices,
controle: textesControle(m, { papier, imposition, planches: nombrePlanches(m.badges.length, papier) }),
filigrane: FILIGRANE,
});
// Les trois productions de la série surveillée.
const octetsA4 = await produirePlanche(entrees(MODELE_A4, 'A4', 'feuille'));
const octetsA4Bis = await produirePlanche(entrees(MODELE_A4, 'A4', 'feuille'));
const octetsLettre = await produirePlanche(entrees(MODELE_LETTRE, 'Lettre', 'pile'));
const A4 = await lirePdf(octetsA4);
const LETTRE = await lirePdf(octetsLettre);
const PRODUCTIONS = [
{ nom: 'A4', papier: 'A4', imposition: 'feuille', lu: A4, modele: MODELE_A4 },
{ nom: 'Lettre', papier: 'Lettre', imposition: 'pile', lu: LETTRE, modele: MODELE_LETTRE },
];
// Le centre d'un fragment de texte, et la cellule qui le contient.
const centre = ({ x, y, l, h }) => [x + l / 2, y + h / 2];
const dans = ([cx, cy], { x, y, largeur, hauteur }) => cx > x && cx < x + largeur && cy > y && cy < y + hauteur;
// Les noms lus dans chaque cellule d'une page de planche, null pour une
// cellule où aucun nom du modèle ne se lit.
function nomsParCellule(page, papier, noms) {
return cellules(papier).map((cellule) => {
const lus = page.textes.filter((t) => noms.includes(t.texte) && dans(centre(t), cellule)).map((t) => t.texte);
assert.ok(lus.length <= 1, `plusieurs noms dans une cellule : ${lus.join(', ')}`);
return lus[0] ?? null;
});
}
// La distance d'une boîte de texte à un segment parallèle à un axe.
function distance({ x, y, l, h }, { x1, y1, x2, y2 }) {
const dx = Math.max(0, Math.min(x1, x2) - (x + l), x - Math.max(x1, x2));
const dy = Math.max(0, Math.min(y1, y2) - (y + h), y - Math.max(y1, y2));
return Math.hypot(dx, dy);
}
// Deux segments colinéaires qui se chevauchent sur une longueur non nulle.
function surLeMemeAxe(a, b) {
const vertical = (s) => Math.abs(s.x1 - s.x2) < TOLERANCE;
const horizontal = (s) => Math.abs(s.y1 - s.y2) < TOLERANCE;
const recouvre = (a1, a2, b1, b2) => Math.min(Math.max(a1, a2), Math.max(b1, b2)) - Math.max(Math.min(a1, a2), Math.min(b1, b2)) > TOLERANCE;
if (vertical(a) && vertical(b)) return Math.abs(a.x1 - b.x1) < 0.01 && recouvre(a.y1, a.y2, b.y1, b.y2);
if (horizontal(a) && horizontal(b)) return Math.abs(a.y1 - b.y1) < 0.01 && recouvre(a.x1, a.x2, b.x1, b.x2);
return false;
}
// Les segments d'une page en [x1, y1, x2, y2], arrondis au centième de
// millimètre, chacun de son extrémité la plus haut à gauche, triés dans
// l'ordre lexicographique : une forme comparable à un attendu.
function normaliser(segments) {
const centieme = (v) => Math.round(v * 100) / 100 + 0; // + 0 : -0 devient 0
const lexicographique = (s, t) => {
const i = s.findIndex((v, k) => v !== t[k]);
return i < 0 ? 0 : s[i] - t[i];
};
return segments
.map(({ x1, y1, x2, y2 }) => {
const [a, b] = [[x1, y1], [x2, y2]].map((p) => p.map(centieme)).sort(lexicographique);
return [...a, ...b];
})
.sort(lexicographique);
}
describe('produirePlanche : le document (§ 11.1)', () => {
test('boîtes 210 × 297 et 215,9 × 279,4 à 0,1 mm près sur chaque page ; ni CropBox ni UserUnit ; ni /ID', () => {
for (const { papier, lu, modele: m } of PRODUCTIONS) {
assert.equal(lu.pages.length, 1 + nombrePlanches(m.badges.length, papier), papier);
for (const page of lu.pages) {
assert.ok(Math.abs(page.boite.largeur - PAPIERS[papier].largeur) < 0.1, `${papier} largeur ${page.boite.largeur}`);
assert.ok(Math.abs(page.boite.hauteur - PAPIERS[papier].hauteur) < 0.1, `${papier} hauteur ${page.boite.hauteur}`);
assert.equal(page.cropBox, null);
assert.equal(page.userUnit, null);
}
assert.equal(lu.id, false, papier);
}
});
test("/Info ne porte que CreationDate quand l'horodatage est connu, et n'existe pas quand il est inconnu", () => {
assert.deepEqual(A4.info, ['CreationDate']);
assert.deepEqual(LETTRE.info, []);
});
test("CreationDate se compose du texte de l'horodatage du placement, décalage local gardé", async () => {
const document = await PDFDocument.load(octetsA4, { updateMetadata: false });
assert.equal(document.getInfoDict().lookup(PDFName.of('CreationDate')).asString(), "D:20261009190000-04'00'");
});
test('les deux exports A4 du même modèle : mêmes octets', () => {
assert.equal(octetsA4.length, octetsA4Bis.length);
assert.ok(Buffer.from(octetsA4).equals(Buffer.from(octetsA4Bis)), 'deux exports diffèrent');
});
test('chaque police porte une ToUnicode ; les deux graisses sont embarquées', () => {
for (const { papier, lu } of PRODUCTIONS) {
for (const { nom, toUnicode } of lu.polices) assert.ok(toUnicode, `${papier} : ${nom} sans ToUnicode`);
assert.equal(lu.polices.length, 2, papier);
}
});
test('« Ôjgq » et « Æ » se lisent caractère par caractère dans la couche de texte', () => {
const lus = A4.pages.slice(1).flatMap(({ textes }) => textes.map(({ texte }) => texte));
const nom = lus.find((texte) => texte.includes('Ôjgq'));
assert.ok(nom !== undefined, `aucun fragment ne porte « Ôjgq » : ${lus.join(' | ')}`);
assert.deepEqual([...nom], [...'Ôjgq Æ Grisaille']);
});
});
describe('produirePlanche : les filets mesurés (§ 11.2, § 11.3)', () => {
test('A4, planche pleine : x = 105 de 0 à 296 ; y = 148 et y = 296 de 0 à 210 — trois axes, rien d\u{2019}autre', () => {
assert.deepEqual(normaliser(A4.pages[1].segments), [
[0, 148, 210, 148],
[0, 296, 210, 296],
[105, 0, 105, 296],
]);
});
test("A4, planche d'un badge : seuls les segments qui bordent la cellule occupée", () => {
assert.deepEqual(normaliser(A4.pages[2].segments), [
[0, 148, 105, 148],
[105, 0, 105, 148],
]);
});
test('Lettre, planche pleine : les cinq axes de l\u{2019}encadrement complet', () => {
assert.deepEqual(normaliser(LETTRE.pages[1].segments), [
[33.95, 34.7, 33.95, 244.7],
[33.95, 34.7, 181.95, 34.7],
[33.95, 139.7, 181.95, 139.7],
[33.95, 244.7, 181.95, 244.7],
[181.95, 34.7, 181.95, 244.7],
]);
});
test("Lettre, planche d'un badge : l'encadrement de la seule cellule occupée", () => {
assert.deepEqual(normaliser(LETTRE.pages[2].segments), [
[33.95, 34.7, 33.95, 139.7],
[33.95, 34.7, 181.95, 34.7],
[33.95, 139.7, 181.95, 139.7],
[181.95, 34.7, 181.95, 139.7],
]);
});
test('page 1 : aucun segment sur un axe de coupe', () => {
for (const { papier, lu } of PRODUCTIONS) {
const axes = filets(papier, cellules(papier).map(() => true));
for (const segment of lu.pages[0].segments) {
assert.ok(!axes.some((axe) => surLeMemeAxe(segment, axe)), `${papier} : ${JSON.stringify(segment)}`);
}
}
});
test('aucun motif de tirets, aucun remplissage, aucune couleur hors des gris, sur aucune page', () => {
for (const { papier, lu } of PRODUCTIONS) {
lu.pages.forEach((page, rang) => {
assert.equal(page.tirets, false, `${papier} page ${rang + 1}`);
assert.equal(page.remplissages, 0, `${papier} page ${rang + 1}`);
for (const [r, g, b] of page.couleurs) assert.ok(r === g && g === b, `${papier} page ${rang + 1} : ${[r, g, b]}`);
});
}
});
});
describe('produirePlanche : les badges (§ 11.3, § 11.4, § 11.6)', () => {
test("l'ordre des noms dans la couche de texte, cellule par cellule, égale imposer : feuille en A4, pile en Lettre", () => {
for (const { papier, imposition, lu, modele: m } of PRODUCTIONS) {
const noms = m.badges.map(({ nom }) => nom);
const attendu = imposer(noms.length, papier, imposition).map((planche) => planche.map((i) => (i === null ? null : noms[i])));
assert.deepEqual(
lu.pages.slice(1).map((page) => nomsParCellule(page, papier, noms)),
attendu,
papier,
);
}
});
test("aucun fragment de texte, filigrane compris, à moins de ZONE_SILENCE_MM d'un axe de coupe ou d'un bord de feuille", () => {
for (const { papier, lu } of PRODUCTIONS) {
const { largeur: W, hauteur: H } = PAPIERS[papier];
const bords = [
{ x1: 0, y1: 0, x2: W, y2: 0 },
{ x1: 0, y1: H, x2: W, y2: H },
{ x1: 0, y1: 0, x2: 0, y2: H },
{ x1: W, y1: 0, x2: W, y2: H },
];
const axes = [...filets(papier, cellules(papier).map(() => true)), ...bords];
lu.pages.slice(1).forEach((page, rang) => {
assert.ok(page.textes.length > 0);
for (const fragment of page.textes) {
const plusProche = Math.min(...axes.map((axe) => distance(fragment, axe)));
assert.ok(plusProche >= ZONE - TOLERANCE, `${papier} planche ${rang + 1}, « ${fragment.texte} » à ${plusProche} mm`);
}
});
}
});
test('Lettre : les badges sont à plat, leur texte monte de bas en haut, leur haut à gauche', () => {
const [premiere] = LETTRE.pages.slice(1);
const nom = premiere.textes.find(({ texte }) => texte === NOMS_LETTRE[0]);
assert.ok(nom.h > nom.l, JSON.stringify(nom));
const premiereLigne = premiere.textes.find(({ texte }) => texte.startsWith('Tour 1'));
// Le nom est en haut du badge : plus à gauche que les lignes de tour.
assert.ok(nom.x < premiereLigne.x, `${nom.x} contre ${premiereLigne.x}`);
});
test('BROUILLON : une fois dans chaque cellule occupée de l\u{2019}A4, aucune dans une cellule vide ; nulle part dans le Lettre retenu', () => {
for (const page of A4.pages.slice(1)) {
const occupees = nomsParCellule(page, 'A4', NOMS_A4).map((nom) => nom !== null);
cellules('A4').forEach((cellule, rang) => {
const marques = page.textes.filter((t) => t.texte === FILIGRANE && dans(centre(t), cellule));
assert.equal(marques.length, occupees[rang] ? 1 : 0, `cellule ${rang}`);
if (!occupees[rang]) assert.deepEqual(page.textes.filter((t) => dans(centre(t), cellule)), []);
});
}
const lus = LETTRE.pages.flatMap(({ textes }) => textes.map(({ texte }) => texte)).join(' ');
assert.ok(!lus.includes(FILIGRANE), lus);
});
test('BROUILLON au trait (mode de rendu 1) : une fois par cellule occupée de chaque planche A4 et une fois sur sa feuille de contrôle ; le Lettre retenu ne le porte jamais', () => {
const [controle, ...planches] = A4.pages;
assert.equal(controle.modesTexte.filter((mode) => mode === 1).length, 1, 'A4 page 1');
planches.forEach((page, rang) => {
const occupees = nomsParCellule(page, 'A4', NOMS_A4).filter((nom) => nom !== null).length;
assert.ok(occupees > 0);
assert.equal(page.modesTexte.filter((mode) => mode === 1).length, occupees, `A4 planche ${rang + 1}`);
});
LETTRE.pages.forEach((page, rang) => {
assert.ok(page.modesTexte.length > 0);
assert.deepEqual(page.modesTexte.filter((mode) => mode !== 0), [], `Lettre page ${rang + 1}`);
});
});
test('chaque texte du badge se lit dans la couche de texte : nom, appartenance, tours, pied', () => {
const page = A4.pages[1];
const lus = new Set(page.textes.map(({ texte }) => texte));
const [premier] = MODELE_A4.badges;
for (const texte of [premier.textes.nom, premier.textes.appartenance, ...premier.textes.lignes, ...premier.textes.pied]) {
assert.ok(lus.has(texte), `« ${texte} » absent : ${[...lus].join(' | ')}`);
}
assert.ok([...lus].some((texte) => texte.includes(`Tour 2 ${TIRET} table`)));
});
});
describe('produirePlanche : la feuille de contrôle, page 1 (§ 11.3)', () => {
// La graduation : le segment horizontal de 100 mm, et les repères qui le
// touchent, verticaux.
function graduation(page) {
const horizontaux = page.segments.filter(({ y1, y2 }) => Math.abs(y1 - y2) < TOLERANCE);
const regles = horizontaux.filter(({ x1, x2 }) => Math.abs(Math.abs(x2 - x1) - 100) < 0.5);
assert.equal(regles.length, 1, JSON.stringify(horizontaux));
const [regle] = regles;
const reperes = page.segments.filter(
({ x1, x2, y1, y2 }) => Math.abs(x1 - x2) < TOLERANCE && Math.abs(Math.max(y1, y2) - regle.y1) < TOLERANCE,
);
return { regle, reperes };
}
test('graduation de 0 à 100 mm, repérée tous les 10 mm, mesurée sur les segments', () => {
for (const { papier, lu } of PRODUCTIONS) {
const { regle, reperes } = graduation(lu.pages[0]);
const debut = Math.min(regle.x1, regle.x2);
assert.ok(Math.abs(Math.abs(regle.x2 - regle.x1) - 100) < 0.01, `${papier} : ${JSON.stringify(regle)}`);
const abscisses = reperes.map(({ x1 }) => x1).sort((a, b) => a - b);
assert.equal(abscisses.length, 11, papier);
abscisses.forEach((x, k) => assert.ok(Math.abs(x - (debut + 10 * k)) < 0.01, `${papier} repère ${k} : ${x}`));
// Dans la marge de la page : la graduation se lit entière.
assert.ok(debut >= MARGE - TOLERANCE && Math.min(...reperes.flatMap(({ y1, y2 }) => [y1, y2])) >= MARGE - TOLERANCE);
}
});
test("les lignes de contrôle se lisent dans la couche de texte : comptes, horodatage, version, papier", () => {
for (const { papier, imposition, lu, modele: m } of PRODUCTIONS) {
const lus = new Set(lu.pages[0].textes.map(({ texte }) => texte));
const lignes = textesControle(m, { papier, imposition, planches: nombrePlanches(m.badges.length, papier) });
for (const ligne of lignes) assert.ok(lus.has(ligne), `${papier} : « ${ligne} » absente de ${[...lus].join(' | ')}`);
}
const lusA4 = A4.pages[0].textes.map(({ texte }) => texte);
assert.ok(lusA4.includes(libelle('planche.controle.comptes', MODELE_A4.comptes)));
assert.ok(lusA4.includes(libelle('planche.pied.horodatage', { horodatage: HORODATAGE })));
assert.ok(LETTRE.pages[0].textes.some(({ texte }) => texte === libelle('planche.pied.horodatageInconnu')));
});
test('BROUILLON sur la feuille de contrôle du brouillon : sa ligne et le filigrane, une fois, hors de la graduation', () => {
const page = A4.pages[0];
const marques = page.textes.filter(({ texte }) => texte === FILIGRANE);
assert.equal(marques.length, 2, JSON.stringify(marques));
const { regle, reperes } = graduation(page);
const xs = [regle.x1, regle.x2];
const ys = reperes.flatMap(({ y1, y2 }) => [y1, y2]);
const zone = { x: Math.min(...xs), y: Math.min(...ys), l: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) };
for (const marque of marques) {
const separees =
marque.x > zone.x + zone.l || marque.x + marque.l < zone.x || marque.y > zone.y + zone.h || marque.y + marque.h < zone.y;
assert.ok(separees, `${JSON.stringify(marque)} sur la graduation ${JSON.stringify(zone)}`);
}
});
});
describe('analyserPlanche : avant de produire (§ 11.5, décision 12)', () => {
test('un nom à glyphe absent : absents le nomme, une fois, et rien ne refuse', async () => {
const m = modele([badge(1, 'Ondine 李'), badge(2, 'Théo 李 Pervenche')]);
assert.deepEqual(await analyserPlanche({ modele: m, polices }), { refus: null, absents: ['李'] });
});
test('un badge à 100 tours : refus PLANCHE_TROP_DE_TOURS, et son rang dans le modèle', async () => {
const m = modele([badge(1, 'Iris Ombrelle'), badge(2, 'Théo Pervenche', { tours: 100 }), badge(3, 'Ondine Lacasse', { tours: 100 })]);
assert.deepEqual(await analyserPlanche({ modele: m, polices }), {
refus: { code: 'PLANCHE_TROP_DE_TOURS', details: { tours: 100 }, rang: 1 },
absents: [],
});
});
test('produirePlanche refuse un modèle que la mise en page refuse, sans rien produire', async () => {
const m = modele([badge(1, 'Iris Ombrelle', { tours: 100 })]);
await assert.rejects(produirePlanche(entrees(m, 'A4', 'feuille')), /PLANCHE_TROP_DE_TOURS/);
});
});
describe('produirePlanche : les entrées hors de leur domaine', () => {
test('un papier ou une imposition inconnus, des lignes de contrôle qui ne sont pas des chaînes : TypeError', async () => {
const base = entrees(MODELE_LETTRE, 'Lettre', 'pile');
await assert.rejects(produirePlanche({ ...base, papier: 'A5' }), TypeError);
await assert.rejects(produirePlanche({ ...base, imposition: 'damier' }), TypeError);
await assert.rejects(produirePlanche({ ...base, controle: [1] }), TypeError);
});
test("un brouillon sans le mot de son filigrane : TypeError ; un horodatage d'une autre forme : TypeError", async () => {
await assert.rejects(produirePlanche({ ...entrees(MODELE_A4, 'A4', 'feuille'), filigrane: undefined }), TypeError);
await assert.rejects(produirePlanche({ ...entrees(MODELE_A4, 'A4', 'feuille'), filigrane: '' }), TypeError);
const horsForme = { ...MODELE_LETTRE, horodatage: '2026-10-09 19:00' };
await assert.rejects(produirePlanche({ ...entrees(MODELE_LETTRE, 'Lettre', 'pile'), modele: horsForme }), TypeError);
});
});

283
test/pdf.js Normal file
View file

@ -0,0 +1,283 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La lecture d'un PDF pour les épreuves, sous node seulement : ce qu'un
// lecteur réel y trouve, en millimètres, l'origine au coin haut gauche de la
// boîte de média. pdfjs-dist, lecteur des navigateurs, rend la couche de
// texte et la liste des opérateurs de dessin ; pdf-lib relit la structure
// du fichier — boîtes de page, polices et leur ToUnicode, /Info, /ID —,
// que pdfjs ne montre pas telle qu'elle est écrite.
//
// Ce module isole le format des opérateurs de la version épinglée de
// pdfjs-dist : constructPath y porte [opérateur de peinture, [données du
// chemin], englobant], les données en suites « code DrawOPS, coordonnées »,
// dans l'espace de l'utilisateur, avant la matrice courante. La lecture
// suit elle-même la matrice courante (q, Q, cm) : une mise à l'échelle
// posée dans le flux déplace les segments qu'elle rend, là où les boîtes de
// page ne changent pas.
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { PDFArray, PDFDict, PDFDocument, PDFName, PDFNumber, PDFRef } from 'pdf-lib';
import { OPS, getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs';
/** Le point typographique en millimètres. */
const MM_PAR_PT = 25.4 / 72;
// Les polices standard de pdfjs, lues du disque quand un document en
// appelle une sans l'embarquer : pdfjs les attend dans un dossier.
const POLICES_STANDARD = `${join(dirname(createRequire(import.meta.url).resolve('pdfjs-dist/package.json')), 'standard_fonts')}/`;
// Les codes des segments de chemin dans les données de constructPath.
const DRAW = Object.freeze({ moveTo: 0, lineTo: 1, curveTo: 2, quadraticCurveTo: 3, closePath: 4 });
// Les opérateurs de peinture qui tracent le contour, et ceux qui
// remplissent : S s, f F f*, B B* b b*.
const TRACENT = new Set([OPS.stroke, OPS.closeStroke, OPS.fillStroke, OPS.eoFillStroke, OPS.closeFillStroke, OPS.closeEOFillStroke]);
const REMPLISSENT = new Set([OPS.fill, OPS.eoFill, OPS.fillStroke, OPS.eoFillStroke, OPS.closeFillStroke, OPS.closeEOFillStroke]);
const COULEURS = new Set([OPS.setFillRGBColor, OPS.setStrokeRGBColor]);
// Les opérateurs qui montrent du texte : Tj TJ ' ".
const MONTRENT = new Set([OPS.showText, OPS.showSpacedText, OPS.nextLineShowText, OPS.nextLineSetSpacingShowText]);
// La composition de deux matrices [a b c d e f] : m1 d'abord, puis m2.
const composer = ([a1, b1, c1, d1, e1, f1], [a2, b2, c2, d2, e2, f2]) => [
a1 * a2 + b1 * c2,
a1 * b2 + b1 * d2,
c1 * a2 + d1 * c2,
c1 * b2 + d1 * d2,
e1 * a2 + f1 * c2 + e2,
e1 * b2 + f1 * d2 + f2,
];
const appliquer = ([a, b, c, d, e, f], x, y) => [a * x + c * y + e, b * x + d * y + f];
// Une couleur « #rrggbb » de pdfjs en [r, g, b].
const enRgb = (hex) => [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16));
// Une boîte [x1 y1 x2 y2] de pdf-lib, en points, en {x, y, largeur, hauteur}
// en millimètres, x et y au coin bas gauche.
function boiteEnMm(tableau) {
const [x1, y1, x2, y2] = tableau.asArray().map((n) => n.asNumber());
return {
x: Math.min(x1, x2) * MM_PAR_PT,
y: Math.min(y1, y2) * MM_PAR_PT,
largeur: Math.abs(x2 - x1) * MM_PAR_PT,
hauteur: Math.abs(y2 - y1) * MM_PAR_PT,
};
}
// La boîte d'un fragment de texte, en points : l'englobant des quatre coins
// du rectangle largeur × hauteur posé sur sa ligne de base, à son origine,
// le long des deux axes de son transform — juste pour un texte tourné. La
// largeur suit le premier axe, la hauteur (le corps) le second.
function boiteDuFragment({ transform, width, height }) {
const [a, b, c, d, e, f] = transform;
const [na, nc] = [Math.hypot(a, b), Math.hypot(c, d)];
const u = na === 0 ? [1, 0] : [a / na, b / na];
const v = nc === 0 ? [0, 1] : [c / nc, d / nc];
const coins = [
[e, f],
[e + width * u[0], f + width * u[1]],
[e + height * v[0], f + height * v[1]],
[e + width * u[0] + height * v[0], f + width * u[1] + height * v[1]],
];
const xs = coins.map(([x]) => x);
const ys = coins.map(([, y]) => y);
return { x1: Math.min(...xs), x2: Math.max(...xs), y1: Math.min(...ys), y2: Math.max(...ys) };
}
// Les segments, remplissages, couleurs et tirets d'une liste d'opérateurs,
// les segments en points sous la matrice courante ; le mode de rendu en
// vigueur à chaque texte montré. La matrice et le mode font partie de
// l'état graphique : q les empile, Q les rend.
function lireDessin({ fnArray, argsArray }) {
const segments = [];
const couleurs = [];
const modesTexte = [];
let remplissages = 0;
let tirets = false;
let matrice = [1, 0, 0, 1, 0, 0];
let mode = 0;
const pile = [];
fnArray.forEach((fn, i) => {
const args = argsArray[i];
if (fn === OPS.save) pile.push({ matrice, mode });
else if (fn === OPS.restore) ({ matrice, mode } = pile.pop() ?? { matrice: [1, 0, 0, 1, 0, 0], mode: 0 });
else if (fn === OPS.transform) matrice = composer(args, matrice);
else if (fn === OPS.setTextRenderingMode) [mode] = args;
else if (MONTRENT.has(fn)) modesTexte.push(mode);
else if (fn === OPS.setDash) tirets ||= args[0].length > 0;
else if (COULEURS.has(fn)) couleurs.push(enRgb(args[0]));
else if (fn === OPS.constructPath) {
const [peinture, [donnees]] = args;
if (REMPLISSENT.has(peinture)) remplissages += 1;
if (!TRACENT.has(peinture) || donnees === null || donnees === undefined) return;
let courant = null;
let depart = null;
const tracer = (vers) => {
if (courant !== null && (courant[0] !== vers[0] || courant[1] !== vers[1])) {
const [x1, y1] = appliquer(matrice, ...courant);
const [x2, y2] = appliquer(matrice, ...vers);
segments.push({ x1, y1, x2, y2 });
}
};
for (let j = 0; j < donnees.length; ) {
const code = donnees[j++];
if (code === DRAW.moveTo) {
courant = [donnees[j++], donnees[j++]];
depart = courant;
} else if (code === DRAW.lineTo) {
const vers = [donnees[j++], donnees[j++]];
tracer(vers);
courant = vers;
} else if (code === DRAW.curveTo) {
j += 4;
courant = [donnees[j++], donnees[j++]];
} else if (code === DRAW.quadraticCurveTo) {
j += 2;
courant = [donnees[j++], donnees[j++]];
} else if (code === DRAW.closePath) {
if (depart !== null) tracer(depart);
courant = depart;
} else {
throw new TypeError(`lirePdf : code de chemin inconnu ${code}`);
}
}
}
});
return { segments, remplissages, couleurs, tirets, modesTexte };
}
// La valeur d'un attribut héritable d'une page (MediaBox, CropBox,
// UserUnit), lue sur la page puis sur ses parents.
function attributHerite(noeud, nom) {
for (let courant = noeud; courant instanceof PDFDict; courant = courant.lookup(PDFName.of('Parent'))) {
const valeur = courant.lookup(PDFName.of(nom));
if (valeur !== undefined) return valeur;
}
return undefined;
}
// La structure lue par pdf-lib : par page, la boîte de média, la CropBox,
// l'UserUnit ; les polices des ressources des pages, une fois chacune, dans
// l'ordre de première rencontre ; les clés de /Info ; la présence de /ID.
async function lireStructure(octets) {
const document = await PDFDocument.load(octets, { updateMetadata: false });
const vues = new Set();
const polices = [];
const pages = document.getPages().map((page) => {
const cropBox = attributHerite(page.node, 'CropBox');
const userUnit = attributHerite(page.node, 'UserUnit');
const dictionnaire = page.node.Resources()?.lookup(PDFName.of('Font'));
if (dictionnaire instanceof PDFDict) {
for (const [, valeur] of dictionnaire.entries()) {
const cle = valeur instanceof PDFRef ? valeur.toString() : valeur;
if (vues.has(cle)) continue;
vues.add(cle);
const police = document.context.lookup(valeur, PDFDict);
polices.push({
nom: police.lookup(PDFName.of('BaseFont'))?.decodeText?.() ?? police.lookup(PDFName.of('BaseFont'))?.asString().slice(1) ?? '',
toUnicode: police.has(PDFName.of('ToUnicode')),
});
}
}
return {
boite: boiteEnMm(attributHerite(page.node, 'MediaBox')),
cropBox: cropBox instanceof PDFArray ? boiteEnMm(cropBox) : null,
userUnit: userUnit instanceof PDFNumber ? userUnit.asNumber() : null,
};
});
const info = document.context.lookup(document.context.trailerInfo.Info);
return {
pages,
polices,
info: info instanceof PDFDict ? info.keys().map((cle) => cle.asString().slice(1)) : [],
id: document.context.trailerInfo.ID !== undefined,
};
}
/**
* Ce qu'un PDF montre à un lecteur, page par page, en millimètres,
* l'origine au coin haut gauche de la boîte de média.
*
* @param {Uint8Array} octets le fichier ; ni modifié ni détaché
* @returns {Promise<{
* pages: Array<{
* boite: {x: number, y: number, largeur: number, hauteur: number},
* cropBox: null | {x: number, y: number, largeur: number, hauteur: number},
* userUnit: number|null,
* textes: Array<{texte: string, x: number, y: number, l: number, h: number}>,
* segments: Array<{x1: number, y1: number, x2: number, y2: number}>,
* remplissages: number,
* couleurs: Array<[number, number, number]>,
* tirets: boolean,
* modesTexte: number[]}>,
* polices: Array<{nom: string, toUnicode: boolean}>,
* info: string[],
* id: boolean}>}
* boite et cropBox : x et y au coin bas gauche, en millimètres du repère
* du PDF ; textes : la boîte de chaque fragment qui n'est pas fait que
* de blancs, x et y à son coin haut gauche ; segments : les côtés droits des chemins tracés,
* ceux d'un chemin seulement rempli exclus ; remplissages : le nombre de
* chemins remplis (f F f* B B* b b*) ; couleurs : chaque couleur posée,
* de trait ou de fond, dans l'ordre ; tirets : un motif de tirets non
* vide posé ; modesTexte : le mode de rendu (Tr) en vigueur à chaque
* opérateur qui montre du texte, dans l'ordre du flux, 0 par défaut, 1
* au trait ; info : les clés de /Info, [] sans /Info ; id : la remorque
* porte /ID.
*/
export async function lirePdf(octets) {
const structure = await lireStructure(octets);
const tache = getDocument({
data: new Uint8Array(octets),
verbosity: 0,
isEvalSupported: false,
disableFontFace: true,
useSystemFonts: false,
standardFontDataUrl: POLICES_STANDARD,
});
const document = await tache.promise;
try {
const pages = [];
for (let rang = 1; rang <= document.numPages; rang += 1) {
const page = await document.getPage(rang);
const { boite, cropBox, userUnit } = structure.pages[rang - 1];
const gauche = boite.x / MM_PAR_PT;
const haut = (boite.y + boite.hauteur) / MM_PAR_PT;
const contenu = await page.getTextContent({ disableNormalization: true });
const textes = contenu.items
// Un fragment fait de blancs ne porte aucune encre : pdfjs en
// intercale entre deux textes d'une même ligne, à cheval sur l'écart.
.filter((item) => typeof item.str === 'string' && item.str.trim() !== '')
.map((item) => {
const { x1, x2, y1, y2 } = boiteDuFragment(item);
return {
texte: item.str,
x: (x1 - gauche) * MM_PAR_PT,
y: (haut - y2) * MM_PAR_PT,
l: (x2 - x1) * MM_PAR_PT,
h: (y2 - y1) * MM_PAR_PT,
};
});
const dessin = lireDessin(await page.getOperatorList());
const enMm = ({ x1, y1, x2, y2 }) => ({
x1: (x1 - gauche) * MM_PAR_PT,
y1: (haut - y1) * MM_PAR_PT,
x2: (x2 - gauche) * MM_PAR_PT,
y2: (haut - y2) * MM_PAR_PT,
});
pages.push({
boite,
cropBox,
userUnit,
textes,
segments: dessin.segments.map(enMm),
remplissages: dessin.remplissages,
couleurs: dessin.couleurs,
tirets: dessin.tirets,
modesTexte: dessin.modesTexte,
});
}
return { pages, polices: structure.polices, info: structure.info, id: structure.id };
} finally {
await tache.destroy();
}
}

222
test/pdf.test.js Normal file
View file

@ -0,0 +1,222 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'aide d'extraction des PDF (test/pdf.js), éprouvée sur des documents
// faits à la main par pdf-lib, dont chaque cote se calcule de tête : la
// boîte d'un texte tourné, le mode de rendu des textes, les segments sous
// la matrice courante, les remplissages, les tirets et les couleurs, les boîtes de page, les polices
// et leur ToUnicode, le dictionnaire /Info et l'identifiant du fichier.
import assert from 'node:assert/strict';
import fontkit from '@pdf-lib/fontkit';
import {
PDFArray,
PDFDocument,
PDFHexString,
PDFName,
PDFNumber,
PDFString,
StandardFonts,
concatTransformationMatrix,
degrees,
grayscale,
popGraphicsState,
pushGraphicsState,
rgb,
setTextRenderingMode,
TextRenderingMode,
} from 'pdf-lib';
import { lirePdf } from './pdf.js';
import { lirePolices } from './polices.js';
import { describe, test } from './lanceur.js';
/** Le point typographique en millimètres. */
const MM_PAR_PT = 25.4 / 72;
const proche = (a, b, tolerance = 1e-6) => Math.abs(a - b) <= tolerance;
/** Un document d'une page de largeur × hauteur points, que `dessiner` remplit. */
async function document(dessiner, { largeur = 600, hauteur = 800, avant = async () => {} } = {}) {
const pdf = await PDFDocument.create({ updateMetadata: false });
pdf.registerFontkit(fontkit);
await avant(pdf);
const page = pdf.addPage([largeur, hauteur]);
await dessiner(page, pdf);
return pdf.save();
}
const noto = (pdf) => pdf.embedFont(lirePolices().regulier, { subset: true });
describe('lirePdf : le texte, en millimètres depuis le coin haut gauche', () => {
test("un texte droit : sa boîte part de sa ligne de base, sa largeur est l'avance, sa hauteur le corps", async () => {
let avance = 0;
const octets = await document(async (page, pdf) => {
const police = await noto(pdf);
avance = police.widthOfTextAtSize('Iris Ombrelle', 20);
page.drawText('Iris Ombrelle', { x: 100, y: 700, size: 20, font: police });
});
const [page] = (await lirePdf(octets)).pages;
assert.equal(page.textes.length, 1);
const [fragment] = page.textes;
assert.equal(fragment.texte, 'Iris Ombrelle');
assert.ok(proche(fragment.x, 100 * MM_PAR_PT), `x ${fragment.x}`);
assert.ok(proche(fragment.y, (800 - 720) * MM_PAR_PT), `y ${fragment.y}`);
assert.ok(proche(fragment.l, avance * MM_PAR_PT, 1e-4), `l ${fragment.l}`);
assert.ok(proche(fragment.h, 20 * MM_PAR_PT), `h ${fragment.h}`);
});
test('un texte tourné de 90° : la boîte est l\u{2019}englobant des quatre coins du rectangle tourné, haute de son avance', async () => {
let avance = 0;
const octets = await document(async (page, pdf) => {
const police = await noto(pdf);
avance = police.widthOfTextAtSize('Théo Pervenche', 20);
page.drawText('Théo Pervenche', { x: 300, y: 300, size: 20, font: police, rotate: degrees(90) });
});
const [fragment] = (await lirePdf(octets)).pages[0].textes;
// Le texte monte depuis (300, 300) ; son haut regarde vers la gauche :
// il couvre x de 280 à 300 et y de 300 à 300 + avance, en points.
assert.ok(proche(fragment.x, 280 * MM_PAR_PT), `x ${fragment.x}`);
assert.ok(proche(fragment.l, 20 * MM_PAR_PT), `l ${fragment.l}`);
assert.ok(proche(fragment.y, (800 - 300 - avance) * MM_PAR_PT, 1e-4), `y ${fragment.y}`);
assert.ok(proche(fragment.h, avance * MM_PAR_PT, 1e-4), `h ${fragment.h}`);
});
test("deux textes d'une même ligne de base, écartés : deux fragments, sans l'espace que le lecteur intercale entre eux", async () => {
const octets = await document(async (page, pdf) => {
const police = await noto(pdf);
page.drawText('Iris', { x: 50, y: 400, size: 12, font: police });
page.drawText('Ombrelle', { x: 300, y: 400, size: 12, font: police });
});
assert.deepEqual((await lirePdf(octets)).pages[0].textes.map(({ texte }) => texte), ['Iris', 'Ombrelle']);
});
test("le mode de rendu de chaque texte montré, dans l'ordre : 0 par défaut, 1 au trait, rendu par Q", async () => {
const octets = await document(async (page, pdf) => {
const police = await noto(pdf);
page.drawText('Iris', { x: 50, y: 500, size: 12, font: police });
page.pushOperators(pushGraphicsState(), setTextRenderingMode(TextRenderingMode.Outline));
page.drawText('Ombrelle', { x: 50, y: 400, size: 12, font: police });
page.pushOperators(popGraphicsState());
page.drawText('Pervenche', { x: 50, y: 300, size: 12, font: police });
});
assert.deepEqual((await lirePdf(octets)).pages[0].modesTexte, [0, 1, 0]);
assert.deepEqual((await lirePdf(await document(() => {}))).pages[0].modesTexte, []);
});
test('les accents et les capitales liées se lisent caractère par caractère, par la ToUnicode', async () => {
const octets = await document(async (page, pdf) => {
page.drawText('Ôjgq Æ Œuvre', { x: 50, y: 400, size: 12, font: await noto(pdf) });
});
const [fragment] = (await lirePdf(octets)).pages[0].textes;
assert.deepEqual([...fragment.texte], [...'Ôjgq Æ Œuvre']);
});
});
describe('lirePdf : les chemins, sous la matrice courante', () => {
test('un trait se lit en millimètres, haut gauche ; une mise à l\u{2019}échelle posée dans le flux le déplace', async () => {
const octets = await document((page) => {
page.drawLine({ start: { x: 72, y: 700 }, end: { x: 144, y: 700 }, thickness: 0.5, color: grayscale(0.5) });
page.pushOperators(pushGraphicsState(), concatTransformationMatrix(2, 0, 0, 2, 0, 0));
page.drawLine({ start: { x: 72, y: 100 }, end: { x: 72, y: 172 }, thickness: 0.5 });
page.pushOperators(popGraphicsState());
page.drawLine({ start: { x: 10, y: 10 }, end: { x: 20, y: 10 }, thickness: 0.5 });
});
const { segments } = (await lirePdf(octets)).pages[0];
const attendus = [
{ x1: 25.4, y1: (800 - 700) * MM_PAR_PT, x2: 50.8, y2: (800 - 700) * MM_PAR_PT },
{ x1: 50.8, y1: (800 - 200) * MM_PAR_PT, x2: 50.8, y2: (800 - 344) * MM_PAR_PT },
{ x1: 10 * MM_PAR_PT, y1: (800 - 10) * MM_PAR_PT, x2: 20 * MM_PAR_PT, y2: (800 - 10) * MM_PAR_PT },
];
assert.equal(segments.length, attendus.length);
segments.forEach((segment, i) => {
for (const cle of ['x1', 'y1', 'x2', 'y2']) {
assert.ok(proche(segment[cle], attendus[i][cle]), `segment ${i} ${cle} : ${segment[cle]} contre ${attendus[i][cle]}`);
}
});
});
test("un rectangle tracé donne ses quatre côtés ; un rectangle rempli compte un remplissage et aucun segment", async () => {
const octets = await document((page) => {
page.drawRectangle({ x: 72, y: 72, width: 72, height: 144, borderWidth: 1, borderColor: grayscale(0) });
page.drawRectangle({ x: 300, y: 300, width: 10, height: 10, color: rgb(1, 0, 0) });
});
const page = (await lirePdf(octets)).pages[0];
assert.equal(page.remplissages, 1);
assert.equal(page.segments.length, 4);
const longueurs = page.segments.map(({ x1, y1, x2, y2 }) => Math.hypot(x2 - x1, y2 - y1)).sort((a, b) => a - b);
[25.4, 25.4, 50.8, 50.8].forEach((attendue, i) => assert.ok(proche(longueurs[i], attendue), `côté ${i} : ${longueurs[i]}`));
});
test('les couleurs posées se lisent en [r, g, b] de 0 à 255 ; les tirets se voient, un motif vide est un trait continu', async () => {
const continu = await document((page) => {
page.drawLine({ start: { x: 0, y: 0 }, end: { x: 10, y: 0 }, color: grayscale(0.5) });
});
const tirete = await document((page) => {
page.drawLine({ start: { x: 0, y: 0 }, end: { x: 10, y: 0 }, color: rgb(1, 0, 0), dashArray: [3, 2] });
});
const [a] = (await lirePdf(continu)).pages;
const [b] = (await lirePdf(tirete)).pages;
assert.equal(a.tirets, false);
assert.deepEqual(a.couleurs, [[128, 128, 128]]);
assert.equal(b.tirets, true);
assert.deepEqual(b.couleurs, [[255, 0, 0]]);
});
});
describe('lirePdf : les boîtes, les polices, /Info et /ID', () => {
test('la boîte de média en millimètres ; ni CropBox ni UserUnit par défaut', async () => {
const page = (await lirePdf(await document(() => {}, { largeur: 595.28, hauteur: 841.89 }))).pages[0];
assert.ok(proche(page.boite.largeur, 595.28 * MM_PAR_PT) && proche(page.boite.hauteur, 841.89 * MM_PAR_PT));
assert.equal(page.boite.x, 0);
assert.equal(page.boite.y, 0);
assert.equal(page.cropBox, null);
assert.equal(page.userUnit, null);
});
test('une CropBox et un UserUnit posés se lisent', async () => {
const octets = await document((page) => {
page.setCropBox(0, 0, 72, 72);
page.node.set(PDFName.of('UserUnit'), PDFNumber.of(2));
});
const page = (await lirePdf(octets)).pages[0];
assert.deepEqual(page.cropBox, { x: 0, y: 0, largeur: 25.4, hauteur: 25.4 });
assert.equal(page.userUnit, 2);
});
test("chaque police de page se nomme, avec ou sans ToUnicode : une police embarquée en porte une, une police standard non", async () => {
const octets = await document(async (page, pdf) => {
page.drawText('a', { x: 10, y: 10, size: 10, font: await noto(pdf) });
page.drawText('b', { x: 10, y: 30, size: 10, font: await pdf.embedFont(StandardFonts.Helvetica) });
});
const { polices } = await lirePdf(octets);
assert.equal(polices.length, 2);
const parNom = (motif) => polices.find(({ nom }) => motif.test(nom));
assert.equal(parNom(/NotoSans/).toUnicode, true);
assert.equal(parNom(/^Helvetica$/).toUnicode, false);
});
test("/Info absent : [] ; posé : ses clés ; /ID se voit dans la remorque", async () => {
assert.deepEqual(await lirePdf(await document(() => {})).then(({ info, id }) => ({ info, id })), { info: [], id: false });
const octets = await document(
() => {},
{
avant: async (pdf) => {
pdf.getInfoDict().set(PDFName.of('CreationDate'), PDFString.of("D:20261009190000-04'00'"));
const identifiant = PDFHexString.of('00112233445566778899aabbccddeeff');
pdf.context.trailerInfo.ID = PDFArray.withContext(pdf.context);
pdf.context.trailerInfo.ID.push(identifiant);
pdf.context.trailerInfo.ID.push(identifiant);
},
},
);
const lu = await lirePdf(octets);
assert.deepEqual(lu.info, ['CreationDate']);
assert.equal(lu.id, true);
});
test("les octets reçus ne sont pas détachés par la lecture : une seconde lecture des mêmes octets rend la même chose", async () => {
const octets = await document(async (page, pdf) => {
page.drawText('Ondine Lacasse', { x: 10, y: 10, size: 10, font: await noto(pdf) });
});
const premiere = await lirePdf(octets);
assert.deepEqual(await lirePdf(octets), premiere);
});
});