Exports et déclinaisons
L'imprimeur veut un PDF à la taille A4 avec du texte vectoriel. Le community manager veut un PNG par réseau. La graphiste veut le SVG pour retoucher l'illustration sans toucher aux chiffres. Le premier PDF produit par le projet pesait 3,4 Mo pour une page : le fond était embarqué sans compression avec perte. Livrer, c'est produire ces trois formats depuis la même scène, en quatre déclinaisons, et le prouver par un test réel.
Le mécanisme : une scène, trois moteurs, quatre formats
flowchart LR F["Faits + Structure (une fois)"] --> A["affiche 1240×1754"] F --> C["carré 1080×1080"] F --> S["story 1080×1920"] F --> L["LinkedIn 1200×627"] A & C & S & L --> K["controler()"] K --> P["PNG (Pillow)"] K --> V["SVG : un groupe par calque"] K --> D["PDF (ReportLab) : texte vectoriel, police embarquée"] K --> Q["6 PNG de calques"] P & V & D & Q --> M["manifeste.json + apercu.html"]
Une déclinaison est une recomposition, pas un redimensionnement. Chaque format reprend les mêmes faits et la même structure, mais sa grille, sa base typographique, son nombre de KPI (3 en affiche et carré, 2 en story et LinkedIn) et la présence de l'illustration lui sont propres.
Le SVG : des calques que les outils reconnaissent
o.append(f'<g id="calque-{c.nom}" inkscape:groupmode="layer" inkscape:label="{c.nom}">')
...
o.append(f'<text x="{_x_ligne(e, ligne):.1f}" y="{y:.1f}" font-family="Inter, Arial, sans-serif" '
f'font-size="{e.taille}" font-weight="{poids}" fill="rgb({r},{g},{b})">{escape(ligne)}</text>')- Chaque calque est un groupe
<g>marqué comme calque Inkscape : il apparaît dans le panneau des calques à l'ouverture. - Le texte reste du texte (sélectionnable, traduisible, lisible par un lecteur d'écran), avec
<title>et<desc>remplis par le titre et le texte alternatif. - Les images sont embarquées en
data:URI : JPEG pour le fond opaque, PNG pour les éléments détourés. Le fichier se suffit à lui-même. - La position y d'un
<text>SVG est la ligne de base, pas le haut de la ligne : le code ajoute la hauteur d'ascendante mesurée par Pillow (getmetrics()), sans quoi tout le texte remonte de presque une ligne (l'ascendante d'Inter vaut 0,97 fois le corps).
Le SVG ne contient pas la police. Sur un poste sans Inter, le navigateur prend Arial, plus large : un titre qui tenait sur deux lignes peut déborder. C'est la limite assumée de ce format ; la parade est en dessous.
Le PDF : vectoriel, à la bonne taille physique
k = 72 / f.dpi # px -> points ; l'affiche à 150 dpi fait un A4 (595 × 842 pt)
pdfmetrics.registerFont(TTFont("Inter", str(POLICES / "Inter-Regular.ttf")))
...
pdf.setFont(NOMS_PDF[e.graisse], e.taille * k)
pdf.drawString(_x_ligne(e, ligne) * k, H - y * k, ligne)ReportLab embarque la police : le PDF s'affiche à l'identique partout, et le texte se sélectionne. L'origine du PDF est en bas à gauche, celle de Pillow en haut à gauche : d'où le H - y. Les images opaques passent en JPEG avant d'être embarquées :
def _pour_pdf(e: ImageEl) -> ImageReader:
img = e.image.resize((round(e.w), round(e.h)))
if img.mode == "RGBA" and img.getchannel("A").getextrema()[0] < 255:
return ImageReader(img) # transparence : PNG sans perte
buf = io.BytesIO()
img.convert("RGB").save(buf, "JPEG", quality=85)
buf.seek(0)
return ImageReader(buf)Mesuré sur l'affiche : 3 371 530 octets avant, 530 052 après, rendu indiscernable à l'œil.
L'imprimeur refuse le PDF : « images à 150 dpi, il nous faut 300 dpi ». Que modifiez-vous ?
Changer seulement le dpi rétrécit la page à un A5, les pixels restant les mêmes. Il faut doubler les dimensions en pixels et les tailles de texte. Le texte vectoriel n'est pas concerné, mais le fond et l'illustration le sont : un fond 1K agrandi à 3508 px se verra, d'où une génération en 2K ou 4K (0,10 à 0,15 $ en Flash Image).
Le community manager demande le visuel LinkedIn « en recadrant simplement l'affiche, ce sera plus cohérent ». Que lui répondez-vous ?
L'affiche est verticale ; un bandeau horizontal en extrait soit une tranche (graphique coupé), soit une réduction (texte illisible). La cohérence est garantie en amont : mêmes faits, même titre, mêmes couleurs. Régénérer le fond ne règle pas le problème de mise en page.
Le test de bout en bout
def verifier_e2e(res: dict, sorties: Path) -> list[str]:
erreurs = []
if res["generateur_images"] == "faux":
erreurs.append("le run n'a pas utilisé le vrai modèle d'image")
for nom, f in res["formats"].items():
for ext in ("png", "svg", "pdf"):
if not (sorties / nom / f"{nom}.{ext}").stat().st_size:
erreurs.append(f"{nom}.{ext} vide")
erreurs += [f"{nom} : {p['regle']} {p['element']}" for p in f["problemes"] if p["gravite"] == "bloquant"]
erreurs += [f"détourage {k} douteux" for k, d in res["detourage"].items() if not d["acceptable"]]
return erreursmake e2e échoue si le run n'a pas appelé le vrai modèle, si un fichier manque, s'il reste un bloquant ou si un détourage est douteux. make run échoue aussi (code 1) dès qu'un bloquant subsiste : les fichiers sont écrits pour qu'on puisse les regarder, mais le pipeline ne prétend pas qu'ils sont publiables.
La CI (ci/github-actions.yml) lance les tests puis le rendu hors ligne et publie sorties/ en artefact : une régression de mise en page casse la CI sans dépenser un centime d'API.
Ce qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
make e2e dit « demande GOOGLE_API_KEY » alors que la clé est exportée dans le shell | le Makefile fait -include .env puis export : la valeur factice de .env écrase celle du shell | renseigner .env, ou make e2e GOOGLE_API_KEY=… (la ligne de commande l'emporte) |
| SVG qui déborde chez la graphiste | police Inter absente de son poste | lui fournir polices/, ou lui livrer le PDF comme référence |
| PDF de plusieurs Mo | images opaques embarquées sans compression avec perte | JPEG pour l'opaque, PNG pour le transparent |
| Story recadrée par l'application | contenu dans les zones d'interface | zones sûres dans Format, contrôle zone_sure |
| Les quatre formats n'ont pas le même titre | structure redemandée au modèle pour chaque format | une seule Structure par run, enregistrée dans le manifeste |
Règle de décision
- PNG pour les réseaux, PDF pour l'impression et l'archivage, SVG pour la retouche.
- Un format de plus = une entrée dans
FORMATSet un passage dans les contrôles, jamais un redimensionnement. make e2evert est la seule définition de « livrable ».
Exercice
Le service juridique impose une mention « Données internes non auditées » sur tous les formats. Où l'ajoutez-vous, et que vérifiez-vous ensuite ?
Voir la solution
Dans la constante SOURCE de mise_en_page.py (« Source : relevés de compteurs 2025-2026, données internes non auditées, … ») : elle est posée par le code sur les quatre formats, dans le calque texte, et apparaît dans le SVG et le PDF comme du vrai texte.
Puis make run et lecture du rapport : la source passe peut-être sur trois lignes en LinkedIn (boîte de 2 lignes) et déclenche debordement, et le nombre de mots augmente les alertes de densité. Corrigez en raccourcissant la mention ou en élargissant la boîte de la source à 3 lignes, puis make e2e.
Résultat obtenu
Exécutions réelles du 2026-10-15. Run final (21:06, MODELE_IMAGE=gemini-3-pro-image, visuels en cache, un appel texte, 35,2 s) :
$ make e2e
affiche 1240x1754 6 calques 0 bloquant(s) 0 alerte(s)
carre 1080x1080 6 calques 0 bloquant(s) 1 alerte(s)
[alerte] densite : carre (99 mots > 90 pour ce format)
story 1080x1920 6 calques 0 bloquant(s) 1 alerte(s)
[alerte] densite : story (93 mots > 90 pour ce format)
linkedin 1200x627 6 calques 0 bloquant(s) 1 alerte(s)
[alerte] densite : linkedin (93 mots > 80 pour ce format)
structure : modele | titre : Baisse de 16,8 % de la consommation en 2026
E2E OK : 4 formats x (PNG, SVG, PDF) + calques, 0 problème bloquantVérification des exports de l'affiche :
PDF : 595 x 842 pt (A4), métadonnée titre « Baisse de 16,8 % de la consommation en 2026 »
texte extrait par pypdf : « Baisse de 16,8 % de la / consommation en 2026 / En 2026, les 6 sites
consomment 6 954 MWh, / avec une part solaire de 30 %. / 6 954 MWh / consommés en 2026 … »
SVG rendu dans Chromium vs PNG Pillow : 1,23 % des pixels diffèrent de plus de 40 niveaux (anticrénelage)
Tailles : affiche.png 1,5 Mo · affiche.svg 0,54 Mo · affiche.pdf 0,53 MoPuis, avec un .venv neuf créé par make setup (17 s), un dernier make e2e à 21:13 (« E2E OK »), et le nettoyage :
$ make clean
Artefacts supprimés.
$ make reset
Artefacts supprimés.
Environnement supprimé (.env conservé).
$ make cloud-down
Aucune ressource Google Cloud créée par ce projet : rien à supprimer.Coût réel de la mise au point : 15 images générées au total (1 essai, 2 infographies « naïves », 6 en Flash Image, 6 en Pro Image), soit environ 1,47 $ aux tarifs publiés, et une quinzaine d'appels texte.
Je sais faire
- Produire PNG, SVG à calques et PDF vectoriel depuis une même scène
- Décliner une infographie en recomposant, pas en redimensionnant
- Écrire un test de bout en bout qui refuse un rendu non publiable
- Nettoyer le projet et vérifier qu'il ne reste rien