Nothing checked that the sheet embeds font subsets rather than whole fonts, nor how thick its cut lines are: subset: false or 5 pt lines left the series green. The PDF reader used by the tests now reports each embedded font program's length and each stroke's width in millimetres under the current matrix. The sheet's programs must weigh under a tenth of their .ttf, and every cut line at most 0.1 mm, in A4 and Letter. Checked: 3856 node tests from the index alone; both mutants caught. --- FR --- [ADD] badges : épreuves des sous-ensembles de police et des filets fins Rien ne vérifiait que la planche embarque des sous-ensembles de police plutôt que les polices entières, ni l'épaisseur de ses filets : subset false ou des filets de 5 pt laissaient la série verte. Le lecteur de PDF des épreuves rend la longueur de chaque programme de police embarqué et l'épaisseur de chaque trait en millimètres sous la matrice courante. Les programmes pèsent moins du dixième de leur .ttf, et chaque filet au plus 0,1 mm, en A4 et en Lettre. Vérifié : 3856 node depuis l'index seul ; les deux mutants tombent. Assisted-by: Claude Opus 5.5
318 lines
14 KiB
JavaScript
318 lines
14 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, leur ToUnicode et leur programme
|
||
// embarqué, /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) et l'épaisseur de trait
|
||
// (w) : une mise à l'échelle posée dans le flux déplace et épaissit 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, PDFRawStream, PDFRef, decodePDFRawStream } 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];
|
||
|
||
// L'épaisseur, en points de la page, d'un trait d'épaisseur w (espace de
|
||
// l'utilisateur) tracé de (x1, y1) vers (x2, y2), sous la matrice m : la
|
||
// largeur du parallélogramme image de la bande du trait, mesurée en travers
|
||
// de sa direction, w·|det m| / |m·u|, u le vecteur unitaire du trait. Juste
|
||
// pour une matrice quelconque : une échelle d'un seul axe épaissit le trait
|
||
// qui la traverse et laisse l'autre tel quel.
|
||
function epaisseurSous([a, b, c, d], w, [x1, y1], [x2, y2]) {
|
||
const longueur = Math.hypot(x2 - x1, y2 - y1);
|
||
const [ux, uy] = [(x2 - x1) / longueur, (y2 - y1) / longueur];
|
||
return (w * Math.abs(a * d - b * c)) / Math.hypot(a * ux + c * uy, b * ux + d * uy);
|
||
}
|
||
|
||
// 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, chacun avec son
|
||
// épaisseur ; le mode de rendu en vigueur à chaque texte montré. La
|
||
// matrice, l'épaisseur de trait (1 par défaut, PDF 32000 § 8.4.1) 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;
|
||
let largeurTrait = 1;
|
||
const pile = [];
|
||
fnArray.forEach((fn, i) => {
|
||
const args = argsArray[i];
|
||
if (fn === OPS.save) pile.push({ matrice, mode, largeurTrait });
|
||
else if (fn === OPS.restore) ({ matrice, mode, largeurTrait } = pile.pop() ?? { matrice: [1, 0, 0, 1, 0, 0], mode: 0, largeurTrait: 1 });
|
||
else if (fn === OPS.transform) matrice = composer(args, matrice);
|
||
else if (fn === OPS.setLineWidth) [largeurTrait] = args;
|
||
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, epaisseur: epaisseurSous(matrice, largeurTrait, courant, vers) });
|
||
}
|
||
};
|
||
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 longueur, en octets, du programme TrueType embarqué d'une police
|
||
// (FontFile2), décodé de ses filtres : sur le dictionnaire de la police
|
||
// pour une police simple, sur sa descendante pour une police composite
|
||
// (Type0). null sans FontFile2 : police standard, ou programme d'une autre
|
||
// forme (FontFile, FontFile3).
|
||
function longueurFontFile2(police) {
|
||
const descendantes = police.lookup(PDFName.of('DescendantFonts'));
|
||
const porteuse = descendantes instanceof PDFArray ? descendantes.lookup(0, PDFDict) : police;
|
||
const descripteur = porteuse.lookup(PDFName.of('FontDescriptor'));
|
||
const programme = descripteur instanceof PDFDict ? descripteur.lookup(PDFName.of('FontFile2')) : undefined;
|
||
if (programme === undefined) return null;
|
||
if (!(programme instanceof PDFRawStream)) throw new TypeError('lirePdf : FontFile2 qui n\u{2019}est pas un flux brut');
|
||
return decodePDFRawStream(programme).decode().length;
|
||
}
|
||
|
||
// 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, avec la longueur de leur programme ; 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')),
|
||
fontFile2: longueurFontFile2(police),
|
||
});
|
||
}
|
||
}
|
||
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, epaisseur: number}>,
|
||
* remplissages: number,
|
||
* couleurs: Array<[number, number, number]>,
|
||
* tirets: boolean,
|
||
* modesTexte: number[]}>,
|
||
* polices: Array<{nom: string, toUnicode: boolean, fontFile2: number|null}>,
|
||
* 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, epaisseur : celle du trait en
|
||
* millimètres, mesurée en travers du segment sous la matrice courante ; 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 ; polices : fontFile2, la longueur en octets du programme
|
||
* TrueType embarqué, décodé, null sans FontFile2 ; 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, epaisseur }) => ({
|
||
x1: (x1 - gauche) * MM_PAR_PT,
|
||
y1: (haut - y1) * MM_PAR_PT,
|
||
x2: (x2 - gauche) * MM_PAR_PT,
|
||
y2: (haut - y2) * MM_PAR_PT,
|
||
epaisseur: epaisseur * 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();
|
||
}
|
||
}
|