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
283 lines
12 KiB
JavaScript
283 lines
12 KiB
JavaScript
// © 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();
|
||
}
|
||
}
|