[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:
parent
8b7c732399
commit
8fb80324e7
5 changed files with 1554 additions and 0 deletions
442
src/pdf/planche.js
Normal file
442
src/pdf/planche.js
Normal 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();
|
||||
}
|
||||
186
src/pdf/planche.long.test.js
Normal file
186
src/pdf/planche.long.test.js
Normal 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
421
src/pdf/planche.test.js
Normal 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
283
test/pdf.js
Normal 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
222
test/pdf.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue