gestion_table_tournante_libre/test/pdf.test.js
Mathieu Benoit 0af6aca64f [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
2026-10-10 18:50:40 -04:00

263 lines
13 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)
// 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
// boîte d'un texte tourné, le mode de rendu des textes, les segments sous
// la matrice courante et leur épaisseur, les remplissages, les tirets et les
// 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 fontkit from '@pdf-lib/fontkit';
import {
PDFArray,
PDFDocument,
PDFHexString,
PDFName,
PDFNumber,
PDFString,
StandardFonts,
concatTransformationMatrix,
degrees,
grayscale,
lineTo,
moveTo,
popGraphicsState,
pushGraphicsState,
rgb,
setLineWidth,
setTextRenderingMode,
stroke,
TextRenderingMode,
} from 'pdf-lib';
import { lirePdf } from './pdf.js';
import { lirePolices } from './polices.js';
import { describe, test } from './lanceur.js';
/** Le point typographique en millimètres. */
const MM_PAR_PT = 25.4 / 72;
const proche = (a, b, tolerance = 1e-6) => Math.abs(a - b) <= tolerance;
/** Un document d'une page de largeur × hauteur points, que `dessiner` remplit. */
async function document(dessiner, { largeur = 600, hauteur = 800, avant = async () => {} } = {}) {
const pdf = await PDFDocument.create({ updateMetadata: false });
pdf.registerFontkit(fontkit);
await avant(pdf);
const page = pdf.addPage([largeur, hauteur]);
await dessiner(page, pdf);
return pdf.save();
}
const noto = (pdf) => pdf.embedFont(lirePolices().regulier, { subset: true });
describe('lirePdf : le texte, en millimètres depuis le coin haut gauche', () => {
test("un texte droit : sa boîte part de sa ligne de base, sa largeur est l'avance, sa hauteur le corps", async () => {
let avance = 0;
const octets = await document(async (page, pdf) => {
const police = await noto(pdf);
avance = police.widthOfTextAtSize('Iris Ombrelle', 20);
page.drawText('Iris Ombrelle', { x: 100, y: 700, size: 20, font: police });
});
const [page] = (await lirePdf(octets)).pages;
assert.equal(page.textes.length, 1);
const [fragment] = page.textes;
assert.equal(fragment.texte, 'Iris Ombrelle');
assert.ok(proche(fragment.x, 100 * MM_PAR_PT), `x ${fragment.x}`);
assert.ok(proche(fragment.y, (800 - 720) * MM_PAR_PT), `y ${fragment.y}`);
assert.ok(proche(fragment.l, avance * MM_PAR_PT, 1e-4), `l ${fragment.l}`);
assert.ok(proche(fragment.h, 20 * MM_PAR_PT), `h ${fragment.h}`);
});
test('un texte tourné de 90° : la boîte est l\u{2019}englobant des quatre coins du rectangle tourné, haute de son avance', async () => {
let avance = 0;
const octets = await document(async (page, pdf) => {
const police = await noto(pdf);
avance = police.widthOfTextAtSize('Théo Pervenche', 20);
page.drawText('Théo Pervenche', { x: 300, y: 300, size: 20, font: police, rotate: degrees(90) });
});
const [fragment] = (await lirePdf(octets)).pages[0].textes;
// Le texte monte depuis (300, 300) ; son haut regarde vers la gauche :
// il couvre x de 280 à 300 et y de 300 à 300 + avance, en points.
assert.ok(proche(fragment.x, 280 * MM_PAR_PT), `x ${fragment.x}`);
assert.ok(proche(fragment.l, 20 * MM_PAR_PT), `l ${fragment.l}`);
assert.ok(proche(fragment.y, (800 - 300 - avance) * MM_PAR_PT, 1e-4), `y ${fragment.y}`);
assert.ok(proche(fragment.h, avance * MM_PAR_PT, 1e-4), `h ${fragment.h}`);
});
test("deux textes d'une même ligne de base, écartés : deux fragments, sans l'espace que le lecteur intercale entre eux", async () => {
const octets = await document(async (page, pdf) => {
const police = await noto(pdf);
page.drawText('Iris', { x: 50, y: 400, size: 12, font: police });
page.drawText('Ombrelle', { x: 300, y: 400, size: 12, font: police });
});
assert.deepEqual((await lirePdf(octets)).pages[0].textes.map(({ texte }) => texte), ['Iris', 'Ombrelle']);
});
test("le mode de rendu de chaque texte montré, dans l'ordre : 0 par défaut, 1 au trait, rendu par Q", async () => {
const octets = await document(async (page, pdf) => {
const police = await noto(pdf);
page.drawText('Iris', { x: 50, y: 500, size: 12, font: police });
page.pushOperators(pushGraphicsState(), setTextRenderingMode(TextRenderingMode.Outline));
page.drawText('Ombrelle', { x: 50, y: 400, size: 12, font: police });
page.pushOperators(popGraphicsState());
page.drawText('Pervenche', { x: 50, y: 300, size: 12, font: police });
});
assert.deepEqual((await lirePdf(octets)).pages[0].modesTexte, [0, 1, 0]);
assert.deepEqual((await lirePdf(await document(() => {}))).pages[0].modesTexte, []);
});
test('les accents et les capitales liées se lisent caractère par caractère, par la ToUnicode', async () => {
const octets = await document(async (page, pdf) => {
page.drawText('Ôjgq Æ Œuvre', { x: 50, y: 400, size: 12, font: await noto(pdf) });
});
const [fragment] = (await lirePdf(octets)).pages[0].textes;
assert.deepEqual([...fragment.texte], [...'Ôjgq Æ Œuvre']);
});
});
describe('lirePdf : les chemins, sous la matrice courante', () => {
test('un trait se lit en millimètres, haut gauche ; une mise à l\u{2019}échelle posée dans le flux le déplace', async () => {
const octets = await document((page) => {
page.drawLine({ start: { x: 72, y: 700 }, end: { x: 144, y: 700 }, thickness: 0.5, color: grayscale(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());
page.drawLine({ start: { x: 10, y: 10 }, end: { x: 20, y: 10 }, thickness: 0.5 });
});
const { segments } = (await lirePdf(octets)).pages[0];
const attendus = [
{ x1: 25.4, y1: (800 - 700) * MM_PAR_PT, x2: 50.8, y2: (800 - 700) * MM_PAR_PT },
{ x1: 50.8, y1: (800 - 200) * MM_PAR_PT, x2: 50.8, y2: (800 - 344) * MM_PAR_PT },
{ x1: 10 * MM_PAR_PT, y1: (800 - 10) * MM_PAR_PT, x2: 20 * MM_PAR_PT, y2: (800 - 10) * MM_PAR_PT },
];
assert.equal(segments.length, attendus.length);
segments.forEach((segment, i) => {
for (const cle of ['x1', 'y1', 'x2', 'y2']) {
assert.ok(proche(segment[cle], attendus[i][cle]), `segment ${i} ${cle} : ${segment[cle]} contre ${attendus[i][cle]}`);
}
});
});
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 () => {
const octets = await document((page) => {
page.drawRectangle({ x: 72, y: 72, width: 72, height: 144, borderWidth: 1, borderColor: grayscale(0) });
page.drawRectangle({ x: 300, y: 300, width: 10, height: 10, color: rgb(1, 0, 0) });
});
const page = (await lirePdf(octets)).pages[0];
assert.equal(page.remplissages, 1);
assert.equal(page.segments.length, 4);
const longueurs = page.segments.map(({ x1, y1, x2, y2 }) => Math.hypot(x2 - x1, y2 - y1)).sort((a, b) => a - b);
[25.4, 25.4, 50.8, 50.8].forEach((attendue, i) => assert.ok(proche(longueurs[i], attendue), `côté ${i} : ${longueurs[i]}`));
});
test('les couleurs posées se lisent en [r, g, b] de 0 à 255 ; les tirets se voient, un motif vide est un trait continu', async () => {
const continu = await document((page) => {
page.drawLine({ start: { x: 0, y: 0 }, end: { x: 10, y: 0 }, color: grayscale(0.5) });
});
const tirete = await document((page) => {
page.drawLine({ start: { x: 0, y: 0 }, end: { x: 10, y: 0 }, color: rgb(1, 0, 0), dashArray: [3, 2] });
});
const [a] = (await lirePdf(continu)).pages;
const [b] = (await lirePdf(tirete)).pages;
assert.equal(a.tirets, false);
assert.deepEqual(a.couleurs, [[128, 128, 128]]);
assert.equal(b.tirets, true);
assert.deepEqual(b.couleurs, [[255, 0, 0]]);
});
});
describe('lirePdf : les boîtes, les polices, /Info et /ID', () => {
test('la boîte de média en millimètres ; ni CropBox ni UserUnit par défaut', async () => {
const page = (await lirePdf(await document(() => {}, { largeur: 595.28, hauteur: 841.89 }))).pages[0];
assert.ok(proche(page.boite.largeur, 595.28 * MM_PAR_PT) && proche(page.boite.hauteur, 841.89 * MM_PAR_PT));
assert.equal(page.boite.x, 0);
assert.equal(page.boite.y, 0);
assert.equal(page.cropBox, null);
assert.equal(page.userUnit, null);
});
test('une CropBox et un UserUnit posés se lisent', async () => {
const octets = await document((page) => {
page.setCropBox(0, 0, 72, 72);
page.node.set(PDFName.of('UserUnit'), PDFNumber.of(2));
});
const page = (await lirePdf(octets)).pages[0];
assert.deepEqual(page.cropBox, { x: 0, y: 0, largeur: 25.4, hauteur: 25.4 });
assert.equal(page.userUnit, 2);
});
test("chaque police de page se nomme, avec ou sans ToUnicode : une police embarquée en porte une, une police standard non", async () => {
const octets = await document(async (page, pdf) => {
page.drawText('a', { x: 10, y: 10, size: 10, font: await noto(pdf) });
page.drawText('b', { x: 10, y: 30, size: 10, font: await pdf.embedFont(StandardFonts.Helvetica) });
});
const { polices } = await lirePdf(octets);
assert.equal(polices.length, 2);
const parNom = (motif) => polices.find(({ nom }) => motif.test(nom));
assert.equal(parNom(/NotoSans/).toUnicode, true);
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 () => {
assert.deepEqual(await lirePdf(await document(() => {})).then(({ info, id }) => ({ info, id })), { info: [], id: false });
const octets = await document(
() => {},
{
avant: async (pdf) => {
pdf.getInfoDict().set(PDFName.of('CreationDate'), PDFString.of("D:20261009190000-04'00'"));
const identifiant = PDFHexString.of('00112233445566778899aabbccddeeff');
pdf.context.trailerInfo.ID = PDFArray.withContext(pdf.context);
pdf.context.trailerInfo.ID.push(identifiant);
pdf.context.trailerInfo.ID.push(identifiant);
},
},
);
const lu = await lirePdf(octets);
assert.deepEqual(lu.info, ['CreationDate']);
assert.equal(lu.id, true);
});
test("les octets reçus ne sont pas détachés par la lecture : une seconde lecture des mêmes octets rend la même chose", async () => {
const octets = await document(async (page, pdf) => {
page.drawText('Ondine Lacasse', { x: 10, y: 10, size: 10, font: await noto(pdf) });
});
const premiere = await lirePdf(octets);
assert.deepEqual(await lirePdf(octets), premiere);
});
});