Leçon 7 — Contrôler et livrer

Exports et déclinaisons

9 min de lecture

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.

Visuel LinkedIn 1200×627 : deux colonnes, deux KPI, graphique à droite

Le SVG : des calques que les outils reconnaissent

files/src/infographie/export.py
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

files/src/infographie/export.py
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 :

files/src/infographie/export.py
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 ?

Le community manager demande le visuel LinkedIn « en recadrant simplement l'affiche, ce sera plus cohérent ». Que lui répondez-vous ?

Le test de bout en bout

files/src/infographie/__main__.py
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 erreurs

make 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ômeCauseParade
make e2e dit « demande GOOGLE_API_KEY » alors que la clé est exportée dans le shellle Makefile fait -include .env puis export : la valeur factice de .env écrase celle du shellrenseigner .env, ou make e2e GOOGLE_API_KEY=… (la ligne de commande l'emporte)
SVG qui déborde chez la graphistepolice Inter absente de son postelui fournir polices/, ou lui livrer le PDF comme référence
PDF de plusieurs Moimages opaques embarquées sans compression avec perteJPEG pour l'opaque, PNG pour le transparent
Story recadrée par l'applicationcontenu dans les zones d'interfacezones sûres dans Format, contrôle zone_sure
Les quatre formats n'ont pas le même titrestructure redemandée au modèle pour chaque formatune 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 FORMATS et un passage dans les contrôles, jamais un redimensionnement.
  • make e2e vert 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 bloquant

Vé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 Mo

Puis, 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