gestion_table_tournante_libre/test/pdf.test.js
Mathieu Benoit 8fb80324e7 [ADD] pdf: badge sheet production with control page and draft mark
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
2026-10-09 15:26:53 -04:00

222 lines
11 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, les remplissages, les tirets et les couleurs, les boîtes de page, les polices
// et leur ToUnicode, 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,
popGraphicsState,
pushGraphicsState,
rgb,
setTextRenderingMode,
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("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("/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);
});
});