Le contrôle automatique
Premier run réel du projet, 2026-10-15 à 20:47. Les tests passaient, la démo hors ligne était propre. Avec un vrai titre proposé par Gemini et de vrais visuels, l'affiche est sortie avec un titre de trois lignes qui recouvrait le sous-titre, et la story avec un graphique en corps 19 px. Personne n'avait encore regardé une image : le contrôle automatique l'a fait avant nous, et make e2e a refusé de valider.
affiche 1240x1754 6 calques 2 bloquant(s) 0 alerte(s)
[bloquant] debordement : titre « Consommation électrique : −16,8 % en 202 » (3 lignes, la boîte en prévoit moins)
[bloquant] chevauchement : titre (touche sous_titre « Sur 6 sites, le volume global »)
story 1080x1920 6 calques 1 bloquant(s) 1 alerte(s)
[bloquant] lisibilite : story (18 textes à 19 px (minimum 24 px pour ce format))
E2E ÉCHEC : affiche : debordement titre « Consommation électrique : −16,8 % en 202 »
make: *** [Makefile:25 : e2e] Erreur 1Le mécanisme : six règles, deux gravités
flowchart TB S["Scene composée"] --> R["Rendu de tous les calques sauf 06-texte"] R --> C1["contraste : pixels réels sous chaque ligne"] S --> C2["debordement : largeur et nombre de lignes vs boîte"] S --> C3["zone_sure : dans le cadre et hors des zones d'interface"] S --> C4["chevauchement : texte/texte, image/texte"] S --> C5["lisibilite : taille minimale du format"] S --> C6["densite : mots, surface de texte"] C1 & C2 & C3 & C4 & C5 --> B["bloquant : make e2e échoue"] C6 --> A["alerte : décision humaine"]
| Règle | Gravité | Mesure |
|---|---|---|
contraste | bloquant | WCAG AA : 4,5:1, ou 3:1 pour le grand texte (≥ 24 px, ou ≥ 18,66 px en gras) |
debordement | bloquant | ligne plus large que sa boîte, ou plus de lignes que prévu |
zone_sure | bloquant | texte hors du cadre ou dans les zones d'interface de la story |
chevauchement | bloquant | deux boîtes de texte, ou une image et un texte, se recouvrent |
lisibilite | bloquant | au moins un texte sous la taille minimale du format |
densite | alerte | nombre de mots au-dessus du plafond du format, texte couvrant plus de 35 % de la surface |
Les seuils de contraste viennent des WCAG. Les plafonds de densité (90 mots pour un carré, 80 pour LinkedIn) sont des réglages maison : ils encodent une préférence éditoriale, d'où leur statut d'alerte.
Le contraste se mesure sur les pixels
Comparer la couleur du texte à la couleur « théorique » du fond ne sert à rien quand le fond est généré. Le contrôle compose tous les calques sauf le texte, puis lit la luminance réelle sous chaque ligne :
dessous = np.asarray(rendre_png(scene, sauf=("texte",)).convert("RGB"), dtype=np.float64)
lum_dessous = luminance(dessous)
for t in textes:
zone = lum_dessous[y0:y1, x0:x1]
lt = float(luminance(t.couleur[:3]))
# pire côté au 5e centile : texte sombre -> pixels les plus sombres du fond, et inversement.
lf = np.percentile(zone, 5) if lt < 0.5 else np.percentile(zone, 95)
ratio = (max(lt, lf) + 0.05) / (min(lt, lf) + 0.05)
if ratio < seuil_wcag(t):
pb.append(Probleme("bloquant", "contraste", nom, f"{ratio:.2f}:1 < {seuil_wcag(t)}:1"))Le 5e centile est un compromis assumé : la moyenne laisserait passer un texte à moitié sur une zone sombre ; le minimum strict bloquerait sur trois pixels de bruit JPEG.
Le contrôle de contraste passe, mais un relecteur trouve le sous-titre de la story difficile à lire sur son téléphone en plein soleil. Que concluez-vous ?
4,5:1 est un plancher d'accessibilité, pas une garantie de confort en plein soleil. Le contrôle fait ce qu'on lui demande ; si l'usage réel l'exige, on relève le seuil par format. Renvoyer la faute à l'écran ignore un retour terrain légitime.
Les autres règles
if t.largeur_max() > t.w + 1:
pb.append(Probleme("bloquant", "debordement", nom, ...))
if t.hauteur_texte > t.h + 1:
pb.append(Probleme("bloquant", "debordement", nom, ...))
if y0 < f.sure_haut or y1 > f.h - f.sure_bas or x0 < 0 or x1 > f.w:
pb.append(Probleme("bloquant", "zone_sure", nom, ...))
...
petits = [t for t in textes if t.taille < f.taille_min]
if petits: # un seul problème agrégé : c'est la mise en page qui est trop dense, pas six étiquettes
pb.append(Probleme("bloquant", "lisibilite", f.nom, ...))La première version signalait chaque étiquette trop petite séparément : 18 lignes de rapport pour une seule cause. Un rapport qu'on ne lit plus ne protège plus rien : les symptômes d'une même cause sont agrégés.
Corriger la cause, pas le seuil
Les trois bloquants du premier run ont été corrigés dans la mise en page, jamais en relâchant un contrôle :
| Bloquant | Cause | Correction |
|---|---|---|
| titre de l'affiche sur 3 lignes | colonne étroite (l'illustration occupe 34 % de la largeur) | titre_ajuste descend d'au plus deux demi-paliers de l'échelle, puis laisse le contrôle bloquer |
| chevauchement titre / sous-titre | conséquence du débordement | disparaît avec la correction précédente |
| story en 19 px | l'illustration et trois KPI mangent la hauteur | deux KPI en story ; composition en deux passes : l'illustration ne prend que la hauteur qui reste au-delà du minimum lisible du graphique |
def composer(fmt, s, faits, visuels):
"""Deux passes pour la story : on mesure la place laissée au graphique sans illustration,
puis l'illustration prend ce qui reste au-delà du minimum lisible. Les données passent avant le décor."""
essai = _composer(fmt, s, faits, visuels, 0)
libre = essai.calque("panneaux").elements[-1].h - hauteur_min_graphique(fmt, len(faits.sites))
illu = min(fmt.h * 0.16, libre)
return _composer(fmt, s, faits, visuels, illu) if illu >= fmt.h * 0.06 else essaiAu run final, cette règle a retiré l'illustration de la story : il ne restait pas 6 % de la hauteur après le graphique. C'est le comportement voulu.
Un collègue propose de passer la taille minimale de la story de 24 à 18 px « pour que l'e2e passe vendredi ». Que répondez-vous ?
Abaisser un seuil pour faire passer un test transforme le contrôle en formalité ; « on remontera après » n'arrive jamais. La correction structurelle existe et prend quelques lignes. Une alerte ne protège pas : elle se lit après la publication.
Tester les contrôles eux-mêmes
Un contrôle qui ne détecte rien est indiscernable d'un contrôle cassé. Les tests fabriquent des cas qui doivent échouer :
def test_texte_illisible_sur_fond_sombre_detecte():
sc = _scene("carre")
sc.calque("fond").elements[0].image = Image.new("RGBA", (1080, 1080), (20, 20, 20, 255))
sc.calque("panneaux").elements.clear() # sans carte, le texte sombre se retrouve sur fond sombre
assert any(p.regle == "contraste" for p in controler(sc))
def test_titre_trop_long_detecte():
sc = _scene("linkedin", titre="Notre réseau d'entrepôts réduit sa consommation électrique de façon durable et mesurable sur tous ses sites")
assert any(p.regle in ("debordement", "chevauchement") for p in controler(sc))Ce qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
| Le contrôle passe, le PDF déborde | texte mesuré avec une autre police que celle du rendu | une seule police, livrée et chargée par chemin, pour la mesure et les trois rendus |
| Faux positif de contraste sur une icône | une icône passe sous une ligne de texte | la règle chevauchement image/texte le signale en premier |
| Rapport ignoré par l'équipe | trop de lignes, trop d'alertes | agréger par cause ; garder les alertes rares |
make e2e vert, texte faux | le contrôle vérifie la forme, pas le sens | la validation des gabarits (leçon 2) et a_relire couvrent le sens |
Règle de décision
- Bloquant : ce qui rend l'infographie illisible ou inaccessible. Alerte : ce qui relève du goût éditorial.
- Un bloquant se corrige dans la mise en page ou le contenu, jamais dans le seuil.
- Chaque règle a un test qui la fait échouer volontairement.
Exercice
Ajoutez une règle marge_illustration : l'illustration doit rester à au moins une gouttière de toute boîte de texte. Écrivez-la et son test.
Voir la solution
Dans controler(), en élargissant la boîte de l'image avant l'intersection :
g = f.base * 0.8
for e in scene.calque("illustration").elements:
zone = (e.x - g, e.y - g, e.x + e.w + g, e.y + e.h + g)
for b, t in boites:
if _intersecte(zone, b):
pb.append(Probleme("alerte", "marge_illustration", e.nom, f"à moins d'une gouttière de {t.role}"))Le test déplace l'illustration de l'affiche contre le titre :
def test_illustration_collee_au_titre():
sc = _scene("affiche")
titre = next(t for t in sc.calque("texte").elements if t.role == "titre")
sc.calque("illustration").elements[0].x = titre.x + titre.largeur_max() + 5
assert any(p.regle == "marge_illustration" for p in controler(sc))Alerte plutôt que bloquant : une illustration un peu serrée reste lisible.
Résultat obtenu
Après corrections, run réel du 2026-10-15 à 21:06 :
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)
E2E OK : 4 formats x (PNG, SVG, PDF) + calques, 0 problème bloquantLes trois alertes de densité restent : la source (12 mots) et les douze étiquettes du graphique pèsent lourd dans les petits formats. Décision éditoriale prise ici : les garder, la source est obligatoire et le graphique est le cœur du message.
Je sais faire
- Mesurer un contraste WCAG sur les pixels réels d'une composition
- Détecter débordement, chevauchement, zone sûre et texte trop petit
- Séparer bloquants et alertes, et agréger les symptômes d'une même cause
- Corriger une mise en page au lieu d'assouplir un seuil