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(