gestion_table_tournante_libre/test/pdf.js

284 lines
12 KiB
JavaScript
Raw Normal View History

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