diff --git a/src/pdf/planche.js b/src/pdf/planche.js new file mode 100644 index 0000000..5786416 --- /dev/null +++ b/src/pdf/planche.js @@ -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} + * @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(); +} diff --git a/src/pdf/planche.long.test.js b/src/pdf/planche.long.test.js new file mode 100644 index 0000000..82ba7d4 --- /dev/null +++ b/src/pdf/planche.long.test.js @@ -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')); + }); +}); diff --git a/src/pdf/planche.test.js b/src/pdf/planche.test.js new file mode 100644 index 0000000..b48743a --- /dev/null +++ b/src/pdf/planche.test.js @@ -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); + }); +}); diff --git a/test/pdf.js b/test/pdf.js new file mode 100644 index 0000000..7907a61 --- /dev/null +++ b/test/pdf.js @@ -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(); + } +} diff --git a/test/pdf.test.js b/test/pdf.test.js new file mode 100644 index 0000000..6b05fcb --- /dev/null +++ b/test/pdf.test.js @@ -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); + }); +});