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
263 lines
13 KiB
JavaScript
263 lines
13 KiB
JavaScript
// © 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);
|
||
});
|
||
});
|