[ADD] badges: tests that fonts are subset and cut lines are thin

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
This commit is contained in:
Mathieu Benoit 2026-10-10 18:50:40 -04:00
parent 6dd2762d8e
commit 0af6aca64f
3 changed files with 124 additions and 22 deletions

View file

@ -3,7 +3,8 @@
// La production de la planche (§ 11.1 à § 11.4), éprouvée sur le fichier // 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 // 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 // page, /Info et /ID, la police en sous-ensemble, les filets mesurés sous
// la matrice courante et leur épaisseur, l'ordre
// des noms dans la couche de texte, la zone de silence, les caractères // 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, // extraits un à un, le filigrane, la feuille de contrôle et sa graduation,
// des octets identiques d'un export à l'autre. // des octets identiques d'un export à l'autre.
@ -199,6 +200,20 @@ describe('produirePlanche : le document (§ 11.1)', () => {
} }
}); });
test('chaque police embarquée est un sous-ensemble : son FontFile2 pèse moins d\u{2019}un dixième du .ttf de sa graisse', () => {
const graisses = [
{ motif: /NotoSans-Regular/, ttf: polices.regulier },
{ motif: /NotoSans-Bold/, ttf: polices.gras },
];
for (const { papier, lu } of PRODUCTIONS) {
for (const { motif, ttf } of graisses) {
const police = lu.polices.find(({ nom }) => motif.test(nom));
assert.ok(police !== undefined, `${papier} : aucune police ${motif} parmi ${lu.polices.map(({ nom }) => nom).join(', ')}`);
assert.ok(police.fontFile2 !== null && police.fontFile2 < ttf.length / 10, `${papier} : ${police.nom} embarque ${police.fontFile2} octets sur ${ttf.length}`);
}
}
});
test('« Ôjgq » et « Æ » se lisent caractère par caractère dans la couche de texte', () => { 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 lus = A4.pages.slice(1).flatMap(({ textes }) => textes.map(({ texte }) => texte));
const nom = lus.find((texte) => texte.includes('Ôjgq')); const nom = lus.find((texte) => texte.includes('Ôjgq'));
@ -242,6 +257,17 @@ describe('produirePlanche : les filets mesurés (§ 11.2, § 11.3)', () => {
]); ]);
}); });
test('chaque filet de coupe est fin : une épaisseur non nulle, d\u{2019}au plus 0,1 mm, sur chaque planche A4 et Lettre', () => {
for (const { papier, lu } of PRODUCTIONS) {
lu.pages.slice(1).forEach((page, rang) => {
assert.ok(page.segments.length > 0, `${papier} planche ${rang + 1}`);
for (const { epaisseur, ...segment } of page.segments) {
assert.ok(epaisseur > 0 && epaisseur <= 0.1, `${papier} planche ${rang + 1} : ${epaisseur} mm sur ${JSON.stringify(segment)}`);
}
});
}
});
test('page 1 : aucun segment sur un axe de coupe', () => { test('page 1 : aucun segment sur un axe de coupe', () => {
for (const { papier, lu } of PRODUCTIONS) { for (const { papier, lu } of PRODUCTIONS) {
const axes = filets(papier, cellules(papier).map(() => true)); const axes = filets(papier, cellules(papier).map(() => true));

View file

@ -5,19 +5,19 @@
// lecteur réel y trouve, en millimètres, l'origine au coin haut gauche de la // 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 // 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 // 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 —, // du fichier — boîtes de page, polices, leur ToUnicode et leur programme
// que pdfjs ne montre pas telle qu'elle est écrite. // 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 // 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 // pdfjs-dist : constructPath y porte [opérateur de peinture, [données du
// chemin], englobant], les données en suites « code DrawOPS, coordonnées », // chemin], englobant], les données en suites « code DrawOPS, coordonnées »,
// dans l'espace de l'utilisateur, avant la matrice courante. La lecture // 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 // suit elle-même la matrice courante (q, Q, cm) et l'épaisseur de trait
// posée dans le flux déplace les segments qu'elle rend, là où les boîtes de // (w) : une mise à l'échelle posée dans le flux déplace et épaissit les
// page ne changent pas. // segments qu'elle rend, là où les boîtes de page ne changent pas.
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { PDFArray, PDFDict, PDFDocument, PDFName, PDFNumber, PDFRef } from 'pdf-lib'; import { PDFArray, PDFDict, PDFDocument, PDFName, PDFNumber, PDFRawStream, PDFRef, decodePDFRawStream } from 'pdf-lib';
import { OPS, getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs'; import { OPS, getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs';
/** Le point typographique en millimètres. */ /** Le point typographique en millimètres. */
@ -49,6 +49,18 @@ const composer = ([a1, b1, c1, d1, e1, f1], [a2, b2, c2, d2, e2, f2]) => [
]; ];
const appliquer = ([a, b, c, d, e, f], x, y) => [a * x + c * y + e, b * x + d * y + f]; 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]. // 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)); const enRgb = (hex) => [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16));
@ -85,9 +97,10 @@ function boiteDuFragment({ transform, width, height }) {
} }
// Les segments, remplissages, couleurs et tirets d'une liste d'opérateurs, // 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 // les segments en points sous la matrice courante, chacun avec son
// vigueur à chaque texte montré. La matrice et le mode font partie de // épaisseur ; le mode de rendu en vigueur à chaque texte montré. La
// l'état graphique : q les empile, Q les rend. // 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 }) { function lireDessin({ fnArray, argsArray }) {
const segments = []; const segments = [];
const couleurs = []; const couleurs = [];
@ -96,12 +109,14 @@ function lireDessin({ fnArray, argsArray }) {
let tirets = false; let tirets = false;
let matrice = [1, 0, 0, 1, 0, 0]; let matrice = [1, 0, 0, 1, 0, 0];
let mode = 0; let mode = 0;
let largeurTrait = 1;
const pile = []; const pile = [];
fnArray.forEach((fn, i) => { fnArray.forEach((fn, i) => {
const args = argsArray[i]; const args = argsArray[i];
if (fn === OPS.save) pile.push({ matrice, mode }); if (fn === OPS.save) pile.push({ matrice, mode, largeurTrait });
else if (fn === OPS.restore) ({ matrice, mode } = pile.pop() ?? { matrice: [1, 0, 0, 1, 0, 0], mode: 0 }); 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.transform) matrice = composer(args, matrice);
else if (fn === OPS.setLineWidth) [largeurTrait] = args;
else if (fn === OPS.setTextRenderingMode) [mode] = args; else if (fn === OPS.setTextRenderingMode) [mode] = args;
else if (MONTRENT.has(fn)) modesTexte.push(mode); else if (MONTRENT.has(fn)) modesTexte.push(mode);
else if (fn === OPS.setDash) tirets ||= args[0].length > 0; else if (fn === OPS.setDash) tirets ||= args[0].length > 0;
@ -116,7 +131,7 @@ function lireDessin({ fnArray, argsArray }) {
if (courant !== null && (courant[0] !== vers[0] || courant[1] !== vers[1])) { if (courant !== null && (courant[0] !== vers[0] || courant[1] !== vers[1])) {
const [x1, y1] = appliquer(matrice, ...courant); const [x1, y1] = appliquer(matrice, ...courant);
const [x2, y2] = appliquer(matrice, ...vers); const [x2, y2] = appliquer(matrice, ...vers);
segments.push({ x1, y1, x2, y2 }); segments.push({ x1, y1, x2, y2, epaisseur: epaisseurSous(matrice, largeurTrait, courant, vers) });
} }
}; };
for (let j = 0; j < donnees.length; ) { for (let j = 0; j < donnees.length; ) {
@ -156,9 +171,25 @@ function attributHerite(noeud, nom) {
return undefined; 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, // 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'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. // 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) { async function lireStructure(octets) {
const document = await PDFDocument.load(octets, { updateMetadata: false }); const document = await PDFDocument.load(octets, { updateMetadata: false });
const vues = new Set(); const vues = new Set();
@ -176,6 +207,7 @@ async function lireStructure(octets) {
polices.push({ polices.push({
nom: police.lookup(PDFName.of('BaseFont'))?.decodeText?.() ?? police.lookup(PDFName.of('BaseFont'))?.asString().slice(1) ?? '', nom: police.lookup(PDFName.of('BaseFont'))?.decodeText?.() ?? police.lookup(PDFName.of('BaseFont'))?.asString().slice(1) ?? '',
toUnicode: police.has(PDFName.of('ToUnicode')), toUnicode: police.has(PDFName.of('ToUnicode')),
fontFile2: longueurFontFile2(police),
}); });
} }
} }
@ -205,24 +237,26 @@ async function lireStructure(octets) {
* cropBox: null | {x: number, y: number, largeur: number, hauteur: number}, * cropBox: null | {x: number, y: number, largeur: number, hauteur: number},
* userUnit: number|null, * userUnit: number|null,
* textes: Array<{texte: string, x: number, y: number, l: number, h: number}>, * textes: Array<{texte: string, x: number, y: number, l: number, h: number}>,
* segments: Array<{x1: number, y1: number, x2: number, y2: number}>, * segments: Array<{x1: number, y1: number, x2: number, y2: number, epaisseur: number}>,
* remplissages: number, * remplissages: number,
* couleurs: Array<[number, number, number]>, * couleurs: Array<[number, number, number]>,
* tirets: boolean, * tirets: boolean,
* modesTexte: number[]}>, * modesTexte: number[]}>,
* polices: Array<{nom: string, toUnicode: boolean}>, * polices: Array<{nom: string, toUnicode: boolean, fontFile2: number|null}>,
* info: string[], * info: string[],
* id: boolean}>} * id: boolean}>}
* boite et cropBox : x et y au coin bas gauche, en millimètres du repère * 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 * 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, * 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 * 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, * 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 * 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 * 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 * 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 * au trait ; polices : fontFile2, la longueur en octets du programme
* porte /ID. * 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) { export async function lirePdf(octets) {
const structure = await lireStructure(octets); const structure = await lireStructure(octets);
@ -258,11 +292,12 @@ export async function lirePdf(octets) {
}; };
}); });
const dessin = lireDessin(await page.getOperatorList()); const dessin = lireDessin(await page.getOperatorList());
const enMm = ({ x1, y1, x2, y2 }) => ({ const enMm = ({ x1, y1, x2, y2, epaisseur }) => ({
x1: (x1 - gauche) * MM_PAR_PT, x1: (x1 - gauche) * MM_PAR_PT,
y1: (haut - y1) * MM_PAR_PT, y1: (haut - y1) * MM_PAR_PT,
x2: (x2 - gauche) * MM_PAR_PT, x2: (x2 - gauche) * MM_PAR_PT,
y2: (haut - y2) * MM_PAR_PT, y2: (haut - y2) * MM_PAR_PT,
epaisseur: epaisseur * MM_PAR_PT,
}); });
pages.push({ pages.push({
boite, boite,

View file

@ -4,8 +4,9 @@
// L'aide d'extraction des PDF (test/pdf.js), éprouvée sur des documents // 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 // 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 // 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 // la matrice courante et leur épaisseur, les remplissages, les tirets et les
// et leur ToUnicode, le dictionnaire /Info et l'identifiant du fichier. // couleurs, les boîtes de page, les polices, leur ToUnicode et la longueur de
// leur programme embarqué, le dictionnaire /Info et l'identifiant du fichier.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fontkit from '@pdf-lib/fontkit'; import fontkit from '@pdf-lib/fontkit';
import { import {
@ -19,10 +20,14 @@ import {
concatTransformationMatrix, concatTransformationMatrix,
degrees, degrees,
grayscale, grayscale,
lineTo,
moveTo,
popGraphicsState, popGraphicsState,
pushGraphicsState, pushGraphicsState,
rgb, rgb,
setLineWidth,
setTextRenderingMode, setTextRenderingMode,
stroke,
TextRenderingMode, TextRenderingMode,
} from 'pdf-lib'; } from 'pdf-lib';
import { lirePdf } from './pdf.js'; import { lirePdf } from './pdf.js';
@ -133,6 +138,27 @@ describe('lirePdf : les chemins, sous la matrice courante', () => {
}); });
}); });
test("l'épaisseur d'un trait se lit en millimètres : celle de setLineWidth sous la matrice courante, mesurée en travers du trait ; 1 pt par défaut, rendue par Q", async () => {
const octets = await document((page) => {
page.drawLine({ start: { x: 72, y: 700 }, end: { x: 144, y: 700 }, thickness: 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(), pushGraphicsState(), concatTransformationMatrix(3, 0, 0, 1, 0, 0));
// Une échelle de 3 en x seulement : un trait horizontal garde son
// épaisseur, un trait vertical la triple.
page.drawLine({ start: { x: 10, y: 600 }, end: { x: 50, y: 600 }, thickness: 0.5 });
page.drawLine({ start: { x: 10, y: 500 }, end: { x: 10, y: 550 }, thickness: 0.5 });
page.pushOperators(popGraphicsState());
// Hors de tout drawLine : l'épaisseur par défaut, puis celle posée.
page.pushOperators(moveTo(10, 300), lineTo(20, 300), stroke());
page.pushOperators(setLineWidth(2), moveTo(10, 200), lineTo(20, 200), stroke());
});
const { segments } = (await lirePdf(octets)).pages[0];
const attendues = [0.5, 1, 0.5, 1.5, 1, 2].map((pt) => pt * MM_PAR_PT);
assert.equal(segments.length, attendues.length);
segments.forEach(({ epaisseur }, i) => assert.ok(proche(epaisseur, attendues[i]), `segment ${i} : ${epaisseur} contre ${attendues[i]}`));
});
test("un rectangle tracé donne ses quatre côtés ; un rectangle rempli compte un remplissage et aucun segment", async () => { 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) => { const octets = await document((page) => {
page.drawRectangle({ x: 72, y: 72, width: 72, height: 144, borderWidth: 1, borderColor: grayscale(0) }); page.drawRectangle({ x: 72, y: 72, width: 72, height: 144, borderWidth: 1, borderColor: grayscale(0) });
@ -193,6 +219,21 @@ describe('lirePdf : les boîtes, les polices, /Info et /ID', () => {
assert.equal(parNom(/^Helvetica$/).toUnicode, false); assert.equal(parNom(/^Helvetica$/).toUnicode, false);
}); });
test("chaque police de page rend la longueur de son programme FontFile2, décodé : le .ttf entier hors sous-ensemble, moins en sous-ensemble ; null sans programme", async () => {
const { regulier, gras } = lirePolices();
const octets = await document(async (page, pdf) => {
page.drawText('a', { x: 10, y: 10, size: 10, font: await pdf.embedFont(regulier, { subset: true }) });
page.drawText('b', { x: 10, y: 30, size: 10, font: await pdf.embedFont(gras, { subset: false }) });
page.drawText('c', { x: 10, y: 50, size: 10, font: await pdf.embedFont(StandardFonts.Helvetica) });
});
const { polices } = await lirePdf(octets);
const parNom = (motif) => polices.find(({ nom }) => motif.test(nom));
assert.equal(parNom(/NotoSans-Bold/).fontFile2, gras.length);
const sousEnsemble = parNom(/NotoSans-Regular/).fontFile2;
assert.ok(sousEnsemble > 0 && sousEnsemble < regulier.length / 10, `sous-ensemble de ${sousEnsemble} octets`);
assert.equal(parNom(/^Helvetica$/).fontFile2, null);
});
test("/Info absent : [] ; posé : ses clés ; /ID se voit dans la remorque", async () => { 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 }); assert.deepEqual(await lirePdf(await document(() => {})).then(({ info, id }) => ({ info, id })), { info: [], id: false });
const octets = await document( const octets = await document(