FPDF pour moi reste une référence en terme de librairie bas niveau pour créer vos PDF et j’avais fait mon premier système (et d’autres) de facturation avec; et récemment j’ai découvert un successeur plutôt digne côté front : jsPDF.
Quand on parle de bas niveau c’est d’une part car vous avez la main et devez gérer quasi absolument tout vous-même (coordonnées x,y, occupation de l’espace, vos marges, vos retours à la ligne ou saut de page, …) et de l’autre car la librairie va écrire pour vous le code PDF natif (https://pdfa.org/).
jsPDF n’est pas le nouveau messie dépourvu d’erreur, elle en a son lot, parfois même agaçante vous demandant pas mal d’effort ou d’ingéniosité, mais dans le paysage actuel c’est une base plus que correcte. Attention toute fois de ne pas confondre convertisseur de contenu HTML vers PDF et jsPDF qui vous permet de le créer/composer. Les convertisseurs, au super rendu et 3 lignes de code, utilisent la librairie canvas pour faire une forme de capture d’écran et injecter l’image dans un PDF, joli mais sans accessibilité (sélection du texte, c’est une image) ni modifications (pour la même raison que c’est une image).
Bien que la documentation existe, elle reste sommaire et j’ai moulte fois dû, et au final préféré, une lecture directe du code disponible sur github. Il est certain que si vous entrez dans des réflexions de faisabilité, de type de paramètre ou de compréhension, vous descendiez à ce niveau régulièrement. Notamment un éternel sujet complexe : la taille d’un texte.
But de l’article
Cet article à pour but de dégrossir des points d’attention pour ceux qui veulent se lancer dans l’écriture de PDF. Il n’est pas aisé sans notion infographique minimum de concevoir un document qui aura du sens et respectera quelques notions « élémentaires ». J’entend par là la notion de marges, de taille de texte, de police d’écriture et de son style, ou encore d’interlignage, d’alignement, d’indentation et j’en passe.
Les points
Dans ce monde vous aurez accès à divers unité, mais je trouve qu’il est plus aisé de rester natif et d’utiliser les points, tel qu’un millimètre = 2.83465 points (se référer aux pouces du coup). Ou encore, une A4, aura pour largeur 595 pts et une hauteur de 842 pts. Et pour compléter ce sujet, un pixel aura un rapport de 0.75 pour 1 pts (pensez à l’unité de vos images par exemple).
Les couleurs
Elles peuvent être gérée par code hexadécimal (#333333) ou par RGB/RGBA (channel(s) en jsPDF), ainsi nous utiliserons une structure, qui prendra place en ch1, ch2, ch3 et ch4 tel que :
export interface RGBColor { r: number, g: number, b: number, a?: number }
Classe de base
Afin de mieux gérer notre contexte, je vous suggère de créer une classe qui vous permettra de conserver votre position x, y, vos marges, les dimensions calculées d’espace disponible, puis la fonte choisie, sa taille, son interlignage, etc.
import { jsPDF } from "jspdf"; export interface Margins { left: number right: number top: number bottom: number } export const DIM_A4_POINTS = { width: 595, height: 842 } export const DIM_MM_POINTS = 2.83465 export const DIM_PX_POINTS = 0.75 // 1.333 export class KPdf { doc: jsPDF margins!: Margins // Defined in constructor x: number y: number pageWidth!: number // Updated by constructor call pageHeight!: number // Updated by constructor call fontSize: number = 10 lineHeight: number = 12 constructor() { this.doc = new jsPDF("p", "pt", "a4") this.setMargins(20 * DIM_MM_POINTS) this.x = this.margins.left this.y = this.margins.top } setMargins(margins: number): KPdf; setMargins(left: number, right?: number, top?: number, bottom?: number): KPdf { this.margins = right ? { left, right, top, bottom } as Margins : { left: left, right: left, top: left, bottom: left } as Margins this.updatePageDims() return this } updatePageDims(): void { // A4 forced format on portait orientation this.pageWidth = DIM_A4_POINTS.width - this.margins.left - this.margins.right this.pageHeight = DIM_A4_POINTS.height - this.margins.top - this.margins.bottom } ...
Cadeau, je vous ai résumé ici quelques éléments de base, voyons ça ensemble.
Marges
Les marges, donc l’espace réservé depuis chaque bord de votre feuille, seront quasi indispensable dans vos calculs et positionnements, l’objet va nous aider à nous y retrouver. L’interface est claire assez, il vous manquera un getMargins() pour l’usage mais je vous laisse l’ajouter ;).
J’ai utilisé ici un double setter permettant de setter les 4 bords d’un coup, égaux entre eux, comme dans le constructeur ou de pouvoir appeler sa version individuelle. Un énième alternative serait le passage d’un objet typé Margins, amusez vous, c’est selon vos besoins :).
Noter le type de retour (KPdf), pour ainsi pouvoir chaîner vos appels, quel confort quand même ;).
Constructeur
Le but est de créer notre base jsPDF correctement paramétré selon nos habitudes, besoins et choix technique, dans notre cas des pages en portait (« p »), en utilisant l’unité point (« pt ») et de dimension A4 (« a4 »).
Une fois établi, on met des marges par défaut, typiquement comme tous les traitement de textes qui vous proposent un modèle clef en main par défaut que personne ne retouche, sauf des gars comme nous ^^. C’est là qu’une première magie peut s’opérer, si on a des marges, connait alors notre origine (x,y) et l’espace disponible pour y mettre notre contenu.
J’ai également mis une taille de caractère (fontSize) à 10pt et son interlignage (lineHeight) à 12 en prenant l’échelle 1.2.
Et voilà vous êtes parti, bon amusement !
… si seulement ^^
Agrémenter KPdf
Vous avez une base, que vous ferez évoluer selon vos besoins au cas par cas de vos projets, perso je me suis mis des getter/setter pour x, y, xy, fontSize, lineHeight, mais aussi pour définir une en-tête et un pied de page. Là, je vous parlerai de delegate ou callback, un moyen de définir de manière extérieur un contenu à une méthode interne actuellement non définie.
export type HeaderPrototype = (pdf: jsPDF) => void export class KPdf { ... headerFct?: HeaderPrototype footerFct?: HeaderPrototype setHeaderFct(fct: HeaderPrototype): KPdf { this.headerFct = fct return this } setFooterFct(fct: HeaderPrototype): KPdf { this.footerFct = fct return this } addHeader(): KPdf { if (this.headerFct) { this.headerFct(this.doc) } return this } addFooter(): KPdf { if (this.footerFct) { this.footerFct(this.doc) } return this } newPage(): KPdf { this.doc.addPage() this.addHeader().addFooter() this.setXY( this.margins.left, this.margins.top ) return this } ...
Exemple ici avec la méthode newPage qui ajoutera une page au document final et appellera de toutes façons l’ajout d’une en-tête et pied de page, évidemment si définit par l’utilisateur (détecté en interne de méthode). Bonus ici, reset des x et y au saut de page.
En démarrage par contre, car jsPDF crée une page par défaut, il faut les ajouter à la main, tel que :
const kpdf = new KPdf() // Setters, margins ... // Manage first page kpdf.addHeader().addFooter()
Gestion du texte
J’ai eu 2 cas différent en terme de traitement de contenu, un premier avec une source Tiptap, dont je parlerai surement dans un autre article sous forme de retour d’expérience, et un autre avec une gestion de cellule de tableau maison, dont je vais un peu vous faire quelques retours.
On utilisera la fonction text pour écrire du texte dans notre PDF et petit conseil en passant, mettez l’option baseline à « top » mieux gérer votre x/y. Autre chose à savoir, si vous voulez aligner à droite, votre x est le x en fin de ligne/bloc et il faut également mettre l’option align à « right » du coup.
Retour à la ligne
Ce n’est pas aussi bête qu’il n’y parait, même si la fonction text à l’air complète elle manque clairement de documentation, cas d’usages clairs.
C’est quoi un retour à la ligne, c’est d’arriver au bout de notre espace disponible, de forcer un retour chariot au début de notre ligne (x), quel qu’il soit (pas forcément la marge gauche, surtout si indentation), de passer une hauteur de ligne (augmenter notre y) et de continuer d’écrire notre contenu (on parle ici de rendu du texte que l’on veut injecter dans le PDF).
La question est comment on sait ça ? Posez-vous juste la question. Vous avez une somme de caractères (un paragraphe par exemple), j’imagine que vous connaissez la taille du texte et que vous êtes prêt de ce côté kpdf.doc.setFontSize(12)
par exemple. Sans faire du suspens inutile, je vous conseille splitTextToSize qui va vous rendre un tableau de string coupé à distance maximum (votre pageWidth par exemple ou la largeur d’une cellule). Si vous injectez le résultat directement dans text, pensez à préciser l’option lineHeightFactor (1.2 dans notre cas), personnellement, pour d’autres raisons, j’ai géré à la main l’écriture de chaque ligne, donc j’utilise ma variable lineHeight et je positionne mon x et y également à la main.
Gras/italique
Si vous avez du gras ou de l’italique dans votre texte d’origine vous risquez d’avoir des surprises, déjà la fonction text ne gère qu’une configuration à la fois, c’est à dire que si j’ai une phrase telle que : « mon contenu est composé », j’aurais virtuellement 3 configurations :
- « mon » : texte normal
- « contenu » : texte gras
- « est composé »: texte normal
Attention, ici on parle au sein d’une seule ligne, c’est encore plus fun (compliqué) quand on gère ça en multilignes. Qu’importe votre façon de gérer cette structure de bloc, vous allez devoir boucler sur cette liste, calculer pour chaque la longueur du bloc de texte et ainsi gérer un x progressive par appel de la méthode text. Grossièrement on aurait (on suppose la gestion des espaces entre blocs) :
this.doc.text("mon ", x, y, { baseline: "top" }) // Change style this.doc.text("contenu ", x + w1, y, { baseline: "top" }) // Change style again this.doc.text("est composé", x + w1 + w2, y, { baseline: "top" })
On voit facilement l’intérêt de la boucle pour faire progresser x successivement. Cela suppose que votre gestion de bloc retiendra la largeur de celui-ci une fois calculé.
Multiligne
Si on pousse le bouchon où le dernier bloc est trop large par rapport à votre pageWidth/taille de bloc, alors on peut subdiviser en coupant au mot (textPart.split(‘ ‘)) et tester chaque mot un à un, puis forcer le retour à la ligne et reprendre. Vous aurez un algo maison combinant par exemple getStringUnitWidth ou encore splitTextToSize et votre gestion d’effets.
Dans mon cas Tiptap, ayant dû gérer pareil cas, j’ai fortement utilisé getStringUnitWidth, mais qui a sa limite concernant le fait qu’il ne gère pas le gras/italique, il faut appliquer un facteur empirique (~1.08) ou trouver le descriptif par caractère de la fonte utilisée pour les cas : gras, italique, gras+italique; et réécrire le calcul de largeur d’un texte donné. C’était le même soucis avec FPDF, tout dépend des métas de la fonte utilisée, mais là j’admet que ça dépasse mon niveau sur le sujet.
Saut de page
Comme on l’a vu pour le retour à la ligne, c’est pareil en y, si on voit que ce que l’on va écrire va déborder au delà de la marge inférieure alors il nous faut créer une nouvelle page tel que vu en exemple de code plus haut, avec en-tête et pied de page, repositionner x et y et reprendre notre rendu. Plus simple quand on a compris le retour à la ligne.
Il faudra cependant prêter attention à l’indentation. Par exemple, si vous êtes en train d’écrire les éléments d’une liste, vous aurez l’indentation pour dessiner la bulle par exemple, et si vous gérer plusieurs niveaux d’imbrication alors là encore plus (pensez récursivité). Hors des listes, l’indentation existe telle que dans un traitement de ligne quand vous utilisez la tabulation ou une règle de positionnement de début de paragraphe.
Tableaux
Un tableau c’est quoi ? Au delà d’une structure à 2 dimensions, ce sont des espaces délimités en largeur (mais pas que, parfois) dans lesquels doit prendre place un contenu. Sans parler des considérations esthétique, de formes et de couleurs, nos cellules sont des blocs, successifs pour lesquels notre logique de retour à la ligne revient.
L’astuce esthétique et logique (x,y) nous oblige à garder en mémoire quelle cellule aura pris le plus de hauteur parmi la ligne courante, sinon, par exemple, si vous faites un zèbre (couleur de fond de ligne) comment l’appliquer sans créer des trous dans certaines cellule ? Vous êtes obligé de faire une passe de calcul (découpe des textes, retours à la lignes, gestion des blocs, …), puis une seconde passe de rendu en profitant des données calculées (couleur de fond, positionnement x,y).
Répétition de l’en-tête
Quand on parle de tableau on oublie souvent de définir ce qu’il se passe quand on saute de page. On aurait tendance à continuer de faire le rendu des cellules sans se tracasser, un peu comme certains tableurs, mais pourquoi ne pas répéter votre en-tête de tableau ? Cela demande une gymnastique à l’image de notre en-tête/pied de page, même logique, un poil plus complexe.
export class KPdf { cellPaddings!: Margins tableHeaderInitFct?: HeaderPrototype tableContentInitFct?: HeaderPrototype constructor() { this.setCellPaddings(0.5 * DIM_MM_POINTS) } // Table part addTableHead(columns: TableColumns[]): KPdf { if (this.tableHeaderInitFct) { this.tableHeaderInitFct(this.doc) } // X, Y, fontSize and LineHeight already set by user this.y += this.cellPaddings.top columns.forEach((c) => { const cellW = this.pageWidth * c.width this.doc.text( c.title, this.x + (c.align === "right" ? cellW - this.cellPaddings.right : this.cellPaddings.left), this.y, { baseline: "top", align: c.align } ) this.x += cellW }) this.y += this.lineHeight + this.cellPaddings.bottom return this } addTableContent(columns: TableColumns[], data: any[], zebraColor: RGBColor = { r: 240, g: 240, b: 240 }): KPdf { if (this.tableContentInitFct) { this.tableContentInitFct(this.doc) } let zebra = true data.forEach((d) => { let maxCellHeight = 0 // Page break if (this.y >= this.margins.top + this.pageHeight) { this.newPage() this.addTableHead(columns) if (this.tableContentInitFct) { this.tableContentInitFct(this.doc) } } }) return this } ...
Donc entre 2 lignes du contenu de votre tableau, on va créer une nouvelle page, remettre l’en-tête et le pied de page, puis on va rappeler l’en-tête de tableau, qui va rappeler le style définit dans une fonction spécifique (et externe), puis remettre notre configuration de corps de tableau et reprendre notre rendu.
Notez que pour le coup nous aurons un nouveau jeu de marges à tenir en compte : le padding des cellules.
Sources
J’ai créé un gist publique avec le code de la classe k-pdf au complet pour ceux que ça intéresse, merci de mentionner le github de l’auteur en cas d’usage ;). J’ai également ajouté un code illustrant l’usage de ce qui a été vu ici, nettoyé, cela va sans dire.
Conclusions
Comme dit, cet article visait à dégrossir le sujet tout en faisant un retour d’expériences. Il n’y a certes pas qu’une façon de faire et le code peut sûrement être amélioré, n’hésitez pas à m’en faire part.
Images
Sujet quelque peu embêtant, car la seule manière trouvée efficace est la base64, pointer vers un asset local (VueJs, Vite) vers addImage ne fonctionne pas.
jsPDF .addImage( "data:image/png;base64,...", "PNG", x, y, wpx * DIM_PX_POINTS, hpx * DIM_PX_POINTS )