gestion_table_tournante_libre/test/pdf.js
Mathieu Benoit 8fb80324e7 [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
2026-10-09 15:26:53 -04:00

283 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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