gestion_table_tournante_libre/test/pdf.test.js

223 lines
11 KiB
JavaScript
Raw Normal View History

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