Écrire un générateur PDF en Go pur, sans aucune dépendance

· Go, formats de fichiers

le besoin

Une de mes applications devait produire un bilan mensuel téléchargeable en PDF. Le projet est un mono-binaire Go sans dépendance externe — ajouter une bibliothèque PDF aurait trahi le principe pour une fonctionnalité qui produit du texte mis en page, rien de plus. J'ai voulu voir ce que coûtait le format lui-même.

ce qu'est un PDF, structurellement

Un PDF texte est plus simple qu'il n'y paraît : une suite d'objets numérotés, une table de références (xref) qui donne l'octet de départ de chacun, et une bande-annonce qui pointe vers la racine.

%PDF-1.4
1 0 obj  << /Type /Catalog /Pages 2 0 R >>           ← racine
2 0 obj  << /Type /Pages /Count 2 /Kids [3 0 R…] >>  ← liste des pages
3 0 obj  << /Type /Page /Contents 4 0 R … >>         ← une page
4 0 obj  << /Length 210 >> stream … endstream       ← son contenu
xref                                                 ← offsets de chaque objet
trailer  << /Size 8 /Root 1 0 R >>
startxref
%%EOF

Le flux de contenu d'une page est un mini-langage de dessin. Pour du texte :

BT /F1 11 Tf 1 0 0 1 50 780 Tm (Bilan de juillet) Tj ET
   │      │                 │                      └ afficher
   │      │                 └ matrice de position (x=50, y=780)
   │      └ police F1, corps 11
   └ début du bloc texte

Les coordonnées partent du coin inférieur gauche, et une page A4 fait 595 × 842 points. La pagination consiste donc à cumuler les hauteurs de ligne et à ouvrir une nouvelle page quand on descend sous la marge basse.

le vrai piege : les accents

Go manipule des chaînes UTF-8. Les polices PDF de base attendent du WinAnsiEncoding — proche de Latin-1. Écrire directement les octets UTF-8 produit un fichier valide où « février » s'affiche « février ».

La conversion est heureusement directe pour le français : les caractères jusqu'à U+00FF gardent leur valeur. Restent les signes typographiques, qui ont des positions spécifiques dans WinAnsi :

€ → 0x80    … → 0x85    ' → 0x92
" → 0x93    – → 0x96    — → 0x97
• → 0x95    ° → 0xB0

Sans cette table, chaque puce et chaque tiret cadratin devient un point d'interrogation. Je l'ai découvert en regardant le fichier produit, pas en relisant le code.

verifier autrement qu'a l'oeil

Mes tests unitaires vérifiaient la présence des marqueurs %PDF, xref, trailer. C'est nécessaire mais très insuffisant : un fichier peut contenir tous ces marqueurs et rester illisible. J'ai donc généré un vrai document et l'ai soumis à des outils indépendants :

$ qpdf --check bilan.pdf
No syntax or stream encoding errors found

$ pdftotext bilan.pdf - | head -3
Bilan de février 2026
Foyer « Chez Jonathan » — Frigo Adaptatif
• 187,50 € de courses — sous ton plafond de 250 €

C'est pdftotext qui a révélé les puces transformées en « ? ». Aucun test unitaire écrit par moi ne l'aurait vu, parce que j'aurais testé ce que je croyais avoir fait.

le bilan

Environ 180 lignes, zéro dépendance, pagination automatique, gras et corps variables. C'est évidemment limité : pas d'images, pas de tableaux, pas de polices embarquées, pas d'Unicode au-delà de Latin-1. Pour un rapport texte, c'est exactement suffisant.

ce que je retiens