Lección 6 — Contrôler et livrer

Le contrôle automatique

9 min de lectura

Este contenido aún no está traducido. Se muestra la versión original.

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.

Premier run réel : le titre de l'affiche recouvre le sous-titre (à gauche), le graphique de la story est en 19 px (à droite)
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 1

Le 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ègleGravitéMesure
contrastebloquantWCAG AA : 4,5:1, ou 3:1 pour le grand texte (≥ 24 px, ou ≥ 18,66 px en gras)
debordementbloquantligne plus large que sa boîte, ou plus de lignes que prévu
zone_surebloquanttexte hors du cadre ou dans les zones d'interface de la story
chevauchementbloquantdeux boîtes de texte, ou une image et un texte, se recouvrent
lisibilitebloquantau moins un texte sous la taille minimale du format
densitealertenombre 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 :

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

Les autres règles

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

BloquantCauseCorrection
titre de l'affiche sur 3 lignescolonne é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-titreconséquence du débordementdisparaît avec la correction précédente
story en 19 pxl'illustration et trois KPI mangent la hauteurdeux KPI en story ; composition en deux passes : l'illustration ne prend que la hauteur qui reste au-delà du minimum lisible du graphique
files/src/infographie/mise_en_page.py
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 essai

Au 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 ?

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 :

files/tests/test_visuels_controles.py
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ômeCauseParade
Le contrôle passe, le PDF débordetexte mesuré avec une autre police que celle du renduune seule police, livrée et chargée par chemin, pour la mesure et les trois rendus
Faux positif de contraste sur une icôneune icône passe sous une ligne de textela règle chevauchement image/texte le signale en premier
Rapport ignoré par l'équipetrop de lignes, trop d'alertesagréger par cause ; garder les alertes rares
make e2e vert, texte fauxle contrôle vérifie la forme, pas le sensla 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 bloquant

Les 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
Siguiente