From 0af6aca64f0c6670524f5b918d74443fde2667e9 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 10 Oct 2026 18:50:40 -0400 Subject: [PATCH] [ADD] badges: tests that fonts are subset and cut lines are thin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/pdf/planche.test.js | 28 +++++++++++++++- test/pdf.js | 73 ++++++++++++++++++++++++++++++----------- test/pdf.test.js | 45 +++++++++++++++++++++++-- 3 files changed, 124 insertions(+), 22 deletions(-) diff --git a/src/pdf/planche.test.js b/src/pdf/planche.test.js index 7960825..515794e 100644 --- a/src/pdf/planche.test.js +++ b/src/pdf/planche.test.js @@ -3,7 +3,8 @@ // 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 -// 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 // extraits un à un, le filigrane, la feuille de contrôle et sa graduation, // 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', () => { const lus = A4.pages.slice(1).flatMap(({ textes }) => textes.map(({ texte }) => texte)); 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', () => { for (const { papier, lu } of PRODUCTIONS) { const axes = filets(papier, cellules(papier).map(() => true)); diff --git a/test/pdf.js b/test/pdf.js index 7907a61..57a0fc7 100644 --- a/test/pdf.js +++ b/test/pdf.js @@ -5,19 +5,19 @@ // 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 // 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 —, -// que pdfjs ne montre pas telle qu'elle est écrite. +// du fichier — boîtes de page, polices, leur ToUnicode et leur programme +// 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 // pdfjs-dist : constructPath y porte [opérateur de peinture, [données du // chemin], englobant], les données en suites « code DrawOPS, coordonnées », // 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 -// posée dans le flux déplace les segments qu'elle rend, là où les boîtes de -// page ne changent pas. +// suit elle-même la matrice courante (q, Q, cm) et l'épaisseur de trait +// (w) : une mise à l'échelle posée dans le flux déplace et épaissit les +// segments qu'elle rend, là où les boîtes de page ne changent pas. import { createRequire } from 'node:module'; 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'; /** 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]; +// 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]. 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 en points sous la matrice courante ; le mode de rendu en -// vigueur à chaque texte montré. La matrice et le mode font partie de -// l'état graphique : q les empile, Q les rend. +// les segments en points sous la matrice courante, chacun avec son +// épaisseur ; le mode de rendu en vigueur à chaque texte montré. La +// 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 }) { const segments = []; const couleurs = []; @@ -96,12 +109,14 @@ function lireDessin({ fnArray, argsArray }) { let tirets = false; let matrice = [1, 0, 0, 1, 0, 0]; let mode = 0; + let largeurTrait = 1; const pile = []; fnArray.forEach((fn, i) => { const args = argsArray[i]; - if (fn === OPS.save) pile.push({ matrice, mode }); - else if (fn === OPS.restore) ({ matrice, mode } = pile.pop() ?? { matrice: [1, 0, 0, 1, 0, 0], mode: 0 }); + if (fn === OPS.save) pile.push({ matrice, mode, largeurTrait }); + 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.setLineWidth) [largeurTrait] = args; else if (fn === OPS.setTextRenderingMode) [mode] = args; else if (MONTRENT.has(fn)) modesTexte.push(mode); 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])) { const [x1, y1] = appliquer(matrice, ...courant); 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; ) { @@ -156,9 +171,25 @@ function attributHerite(noeud, nom) { 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, // 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) { const document = await PDFDocument.load(octets, { updateMetadata: false }); const vues = new Set(); @@ -176,6 +207,7 @@ async function lireStructure(octets) { polices.push({ nom: police.lookup(PDFName.of('BaseFont'))?.decodeText?.() ?? police.lookup(PDFName.of('BaseFont'))?.asString().slice(1) ?? '', 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}, * userUnit: number|null, * 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, * couleurs: Array<[number, number, number]>, * tirets: boolean, * modesTexte: number[]}>, - * polices: Array<{nom: string, toUnicode: boolean}>, + * polices: Array<{nom: string, toUnicode: boolean, fontFile2: number|null}>, * info: string[], * id: boolean}>} * 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 * 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, * 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 * 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 - * porte /ID. + * au trait ; polices : fontFile2, la longueur en octets du programme + * 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) { const structure = await lireStructure(octets); @@ -258,11 +292,12 @@ export async function lirePdf(octets) { }; }); 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, y1: (haut - y1) * MM_PAR_PT, x2: (x2 - gauche) * MM_PAR_PT, y2: (haut - y2) * MM_PAR_PT, + epaisseur: epaisseur * MM_PAR_PT, }); pages.push({ boite, diff --git a/test/pdf.test.js b/test/pdf.test.js index 6b05fcb..66a9a5e 100644 --- a/test/pdf.test.js +++ b/test/pdf.test.js @@ -4,8 +4,9 @@ // 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. +// 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 { @@ -19,10 +20,14 @@ import { concatTransformationMatrix, degrees, grayscale, + lineTo, + moveTo, popGraphicsState, pushGraphicsState, rgb, + setLineWidth, setTextRenderingMode, + stroke, TextRenderingMode, } from 'pdf-lib'; 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 () => { const octets = await document((page) => { 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); }); + 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(