Accéder au contenu principal

Tutoriel GLM Image : créer un générateur de deck infographique

Apprenez à utiliser GLM-Image de Z.ai pour générer un deck d’infographies complet à partir d’un seul prompt.
Actualisé 19 sept. 2026  · 12 min lire

Explorer avec l’IA

ChatGPTClaudePerplexity

Les infographies et jeux de slides comptent parmi les cas d’usage les plus exigeants en génération d’images : il ne s’agit pas seulement de visuels, mais d’informations structurées (titres, listes à puces, graphiques, pied de page) avec un texte lisible et une mise en page cohérente. GLM-Image est le modèle de génération d’images de Z.ai, conçu pour produire des images haute résolution, riches en texte et sensibles à la mise en page, à partir de contraintes de design explicites.

Dans ce tutoriel, je vous montre comment construire un générateur de deck infographique à partir d’un prompt, via une application Streamlit simple. Vous pouvez saisir quasiment n’importe quel sujet. Gemini 2.5 Flash effectue une recherche web à jour pour récupérer des statistiques et tendances récentes, le LLM convertit ces résultats en un plan JSON strict de slides, et GLM-Image rend chaque slide sous forme d’infographie en haute définition.

Au final, vous disposerez d’une chaîne complète qui transforme un prompt en deck téléchargeable, sans GPU local : tout le calcul intensif passe par des API. Découvrez la vidéo accélérée du flux de travail :

Qu’est-ce que GLM Image ?

GLM-Image est le modèle texte-vers-image de Z.ai (accessible via l’ID de modèle glm-image) spécialement conçu pour gérer des générations denses en connaissances et sensibles à la mise en page, comme des affiches, des visuels type PPT et des schémas scientifiques.

Figure : GLM-Image : Auto-régressif pour une génération d’images à forte densité de connaissances et haute fidélité

Sous le capot, il combine un décodeur hybride auto-régressif et diffusion : l’ossature auto-régressive gère la compréhension des consignes et la mise en page globale, tandis que le décodeur par diffusion affine les détails haute fréquence (dont des tracés de texte plus nets). Ce montage hybride explique en grande partie pourquoi GLM-Image est souvent plus fiable que les générateurs purement diffusion pour des visuels très chargés en texte.

Dans ce tutoriel, nous utilisons GLM-Image comme moteur de rendu pour chaque slide. Plutôt que de le solliciter avec un prompt vague, nous lui fournissons un prompt strict et conscient de la mise en page, qui verrouille :

  • Une couleur d’arrière-plan unie (sans dégradé),
  • Des contraintes de palette (arrière-plan / accent / texte),
  • Une hiérarchie typographique claire (titre vs corps vs note de bas de page),
  • Des règles de mise en page explicites selon le type de slide (stat, comparaison, chronologie, processus, liste),
  • Des instructions optionnelles de tracé de graphique (barres / camembert / courbe),
  • Des notes de pied de page et une numérotation des slides.

Note : GLM-Image renvoie une URL d’image, gère des formats usuels de présentation comme le 16:9 et fonctionne idéalement avec des tailles recommandées comme 1728×960.

Tutoriel GLM Image : créer un générateur d’infographies en temps réel

Dans cette section, nous allons construire un pipeline de slides infographiques à partir d’un prompt, propulsé par Gemini 2.5 Flash et GLM-Image, le tout emballé dans une application Streamlit. Vue d’ensemble : l’app effectue trois tâches clés :

  • Rechercher un sujet avec des données web récentes, afin que le deck reflète des chiffres et tendances actuels, et non une mémoire de modèle périmée
  • Convertir la recherche en un plan de slides structuré incluant un schéma JSON strict définissant des mises en page comme stat, chronologie, comparaison, processus et synthèse
  • Rendre chaque slide en image d’infographie complète via GLM-Image, avec hiérarchie typographique, couleurs du thème, contraintes de layout, graphiques optionnels et numérotation.

En coulisses, le flux se déroule en deux phases clés :

  • Phase 1 : la fonction research_topic(topic) utilise Gemini 2.5 Flash avec recherche web activée pour produire une synthèse riche en données, adaptée au sujet visé.
  • Phase 2 : la fonction structure_slides() transforme cette recherche en deck JSON, puis en prompts GLM-Image détaillés, rendus slide par slide.

Étape 1 : obtenir des clés d’API

Avant de construire le générateur d’infographies, nous avons besoin d’accéder à deux services externes qui alimentent le cœur du pipeline :

  • OpenRouter donne accès à Gemini 2.5 Flash, que nous utilisons pour la recherche web et la structuration des slides.
  • Z.ai héberge le modèle GLM-Image via API et HuggingFace, qui génère les slides infographiques finaux.

Cette séparation est volontaire : le modèle texte gère la collecte et la planification, tandis que GLM-Image se concentre exclusivement sur le rendu visuel.

Étape 1.1 : clé d’API OpenRouter

OpenRouter sert de passerelle unifiée vers plusieurs modèles (Gemini, OpenAI, Claude, etc.) avec une clé unique.

  • Créez un compte sur OpenRouter et générez une clé d’API.
  • Ajoutez un petit crédit (environ 5 $). Cela suffit pour plusieurs exécutions de cette démo et des essais futurs.

Dans ce tutoriel, nous utilisons Gemini 2.5 Flash car il prend en charge la recherche web, permettant à l’app de récupérer des données à jour plutôt que de s’appuyer uniquement sur l’entraînement du modèle. Vous pouvez le remplacer par un autre modèle pris en charge si vous préférez.

Étape 1.2 : clé d’API Z.ai

Créez ensuite un compte sur Z.ai pour accéder au modèle GLM Image.

Z.ai API Key

  • Accédez à API Management puis Billing
  • Cliquez sur Add Payment Method et saisissez vos informations de carte
  • Générez une clé d’API pour l’endpoint de génération d’images

Cette clé sera utilisée pour appeler le modèle glm-image afin de rendre chaque slide.

Étape 1.3 : stocker les clés en variables d’environnement

Une fois les deux clés créées, stockez-les en variables d’environnement avec les commandes suivantes :

export OPENROUTER_API_KEY="Your_OpenRouter_API_Key"
export ZAI_API_KEY="Your_ZAI_API_Key"

Cela évite d’exposer vos identifiants dans le code et facilite le déploiement sur Streamlit Cloud, Docker ou d’autres environnements.

Étape 2 : installer les dépendances

Installons maintenant les dépendances requises et commençons à construire l’application Streamlit.

pip install streamlit requests Pillow

Une fois les packages installés, importons les bibliothèques standards utilisées dans l’app, ainsi que quelques constantes pour définir les deux endpoints d’API.

import streamlit as st
import json
import time
import requests
import os
import io
import re
import zipfile
from datetime import date
from PIL import Image
from typing import Optional, List, Dict
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"
OPENROUTER_MODEL = "google/gemini-2.5-flash"
GLM_IMAGE_API_URL = "https://api.z.ai/api/paas/v4/images/generations"
GLM_IMAGE_MODEL = "glm-image"

Ces bibliothèques constituent la couche d’exécution de l’application. Streamlit gère l’interface web interactive, Requests les appels aux API OpenRouter (Gemini 2.5 Flash) et Z.ai (GLM-Image), et Pillow nous permet de charger et d’afficher les images générées. Des bibliothèques standard complémentaires gèrent l’analyse JSON, les variables d’environnement, le timing et l’export en fichiers ZIP.

Nous définissons également des constantes pour les endpoints OpenRouter et GLM-Image afin d’aiguiller les requêtes de recherche, de structuration et de génération d’images. Cette configuration maintient un environnement léger : toute la charge de calcul est prise en charge par les API.

Étape 3 : définir les résolutions et les thèmes de présentation

Définissons un petit ensemble de préréglages de résolution et une bibliothèque de thèmes contrôlant la couleur d’arrière-plan, la couleur d’accent, l’ambiance typographique et les formulations de style d’infographie injectées dans chaque prompt GLM-Image.

RESOLUTIONS = {
    "16:9 Landscape (1728×960)": "1728x960",
    "3:2 Landscape (1568×1056)": "1568x1056",
    "1:1 Square (1280×1280)": "1280x1280",
    "2:3 Portrait (1056×1568)": "1056x1568",
}
THEMES = {
    "Corporate Navy": {
        "desc": "Palette bleu foncé professionnelle, sans empattement, orientée données",
        "colors": {"bg": "#0F172A", "accent": "#38BDF8", "text": "#F1F5F9"},
        "style": (
            "professional corporate infographic, dark navy blue (#0F172A) background, "
            "cyan (#38BDF8) accent color, clean white (#F1F5F9) text, "
            "modern sans-serif typography, clean grid layout, subtle geometric decorations, "
            "professional data visualization elements, business presentation quality"
        ),
    },
    "Neon Noir": {
        "desc": "Mode sombre avec accents rose néon vifs, esthétique tech",
        "colors": {"bg": "#1A1A2E", "accent": "#E94560", "text": "#EAEAEA"},
        "style": (
            "dark mode tech infographic, deep dark (#1A1A2E) background, "
            "vivid neon pink (#E94560) accent color, light gray (#EAEAEA) text, "
            "bold modern typography with sharp edges, neon glow effects, "
            "futuristic layout with geometric shapes, startup pitch deck aesthetic"
        ),
    },
    "Warm Paper": {
        "desc": "Fonds crème texturés, accents orange brûlé, esprit éditorial",
        "colors": {"bg": "#F8F5F0", "accent": "#D4641A", "text": "#1C1917"},
        "style": (
            "warm editorial infographic on cream (#F8F5F0) textured paper background, "
            "burnt orange (#D4641A) accent color, dark brown (#1C1917) text, "
            "elegant serif typography mixed with clean sans-serif, "
            "magazine editorial layout, hand-drawn illustration elements, "
            "warm earth tones, scholarly professional design"
        ),
    },
    "Cyber Teal": {
        "desc": "Fonds noirs, rehauts turquoise électrique, esprit terminal",
        "colors": {"bg": "#0C0A09", "accent": "#22D3EE", "text": "#FAFAF9"},
        "style": (
            "cyberpunk infographic, black (#0C0A09) background, "
            "electric teal (#22D3EE) accent color, white (#FAFAF9) text, "
            "monospace and tech typography, terminal-inspired layout elements, "
            "circuit board patterns, data stream visuals, hacker aesthetic, "
            "sharp angular design elements"
        ),
    },
    "Indigo Dream": {
        "desc": "Base pourpre profonde, accents violet doux, rendu premium",
        "colors": {"bg": "#1E1B4B", "accent": "#A78BFA", "text": "#EDE9FE"},
        "style": (
            "premium luxury infographic, deep indigo (#1E1B4B) background, "
            "soft violet (#A78BFA) accent color, lavender (#EDE9FE) text, "
            "elegant modern typography, gradient overlays, "
            "smooth rounded shapes, glassmorphism effects, "
            "high-end presentation quality with subtle glow effects"
        ),
    },
    "Solar Gold": {
        "desc": "Crème chaud et accents dorés, affirmé et audacieux",
        "colors": {"bg": "#FEFCE8", "accent": "#A16207", "text": "#1C1917"},
        "style": (
            "premium golden infographic, warm cream (#FEFCE8) background, "
            "rich gold (#A16207) accent color, dark (#1C1917) text, "
            "bold confident typography, art deco inspired geometric patterns, "
            "luxury brand aesthetic, clean information hierarchy, "
            "warm highlights and gold foil style embellishments"
        ),
    },
    "Forest Green": {
        "desc": "Palette vert foncé, inspirée de la nature, narration de données apaisante",
        "colors": {"bg": "#052E16", "accent": "#4ADE80", "text": "#F0FDF4"},
        "style": (
            "nature-inspired infographic, deep forest green (#052E16) background, "
            "bright green (#4ADE80) accent color, light mint (#F0FDF4) text, "
            "organic flowing typography, leaf and nature motif decorations, "
            "sustainability report aesthetic, calming visual design, "
            "clean environmental data presentation"
        ),
    },
    "Crimson Bold": {
        "desc": "Rouge sur fond sombre, fort contraste, style affiche percutant",
        "colors": {"bg": "#1C1917", "accent": "#EF4444", "text": "#FAFAF9"},
        "style": (
            "bold poster-style infographic, dark charcoal (#1C1917) background, "
            "vivid crimson red (#EF4444) accent color, white (#FAFAF9) text, "
            "oversized impactful numbers, bold condensed typography, "
            "high contrast design, attention-grabbing layout, "
            "social media optimized visual hierarchy"
        ),
    },
}
EXAMPLE_TOPICS = [
    "AI Industry Market Trends January 2026",
    "Global Electric Vehicle Sales 2025",
    "SpaceX Starship Progress 2025-2026",
    "State of Remote Work 2026",
    "Bitcoin & Crypto Market February 2026",
    "Climate Change Key Metrics 2025",
]

Le dictionnaire RESOLUTIONS associe un libellé à la chaîne LxH attendue par l’endpoint de génération d’images de Z.ai. L’avantage : la prédictibilité. Vous pouvez changer de format (slides larges, carrés pour les réseaux sociaux, portrait) sans toucher à la logique des prompts. Les ratios :

  • 16:9, idéal pour les jeux de présentation classiques et les infographies de type slides.
  • 1:1, adapté aux carrousels LinkedIn/Twitter.
  • 2:3, utile pour des affiches infographiques pensées mobile-first.

Nous définissons aussi des THEMES où chaque thème est un mini système de design en trois parties :

  • desc : une brève description affichée dans la barre latérale
  • colors : notre palette stricte (arrière-plan, accent, texte)
  • style : le bloc de style promptable qui oriente GLM-Image vers une cohérence de mise en page, typographie et décor

Concrètement, cet objet thème devient un « préfixe de prompt » réutilisable pour chaque slide, afin que le deck ressemble à un ensemble homogène plutôt qu’à 7 images sans lien.

Étape 4 : phase 1 – recherche avec Gemini 2.5 Flash

Avant de générer des slides, nous avons besoin de données à jour sur le sujet. Plutôt que de compter sur une connaissance statique, cette étape exploite Gemini 2.5 Flash via OpenRouter, avec recherche web activée, pour collecter statistiques, chronologies et insights récents.

def call_openrouter(
    messages: List[Dict],
    api_key: str,
    use_web_search: bool = False,
    temperature: float = 0.7,
    max_tokens: int = 4096,
) -> str:
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
        "HTTP-Referer": "https://glm-image-infographic-demo.streamlit.app",
        "X-Title": "GLM-Image Infographic Generator",
    }

    payload = {
        "model": OPENROUTER_MODEL,
        "messages": messages,
        "temperature": temperature,
        "max_tokens": max_tokens,
    }
    if use_web_search:
        today = date.today().strftime("%B %d, %Y")
        payload["plugins"] = [{
            "id": "web",
            "max_results": 10,
            "search_prompt": (
                f"Use these recent web search results (searched on {today}) to answer "
                f"with the most up-to-date information available:"
            ),
        }]

    resp = requests.post(OPENROUTER_API_URL, headers=headers, json=payload, timeout=120)
    resp.raise_for_status()
    data = resp.json()
    content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
    if not content:
        raise ValueError(f"Empty response from OpenRouter: {json.dumps(data, indent=2)[:500]}")
    return content
def research_topic(topic: str, api_key: str) -> str:
    today = date.today().strftime("%B %d, %Y")  # e.g. "February 13, 2026"
    current_year = date.today().year

    messages = [
        {
            "role": "system",
            "content": (
                f"You are a focused research analyst. Today's date is {today}. "
                f"CRITICAL: You must research ONLY the specific topic provided. Do NOT include "
                f"information about unrelated topics, industries, or subjects. If web search returns "
                f"irrelevant results, ignore them completely. "
                f"Your training data may be outdated. Rely primarily on the web search results "
                f"provided to you for current data. Prioritize sources from {current_year} and "
                f"late {current_year - 1}. If a data point comes from an older source, note the year. "
                f"Compile comprehensive, data-rich research on the given topic. Include specific "
                f"numbers, percentages, dollar amounts, growth rates, timelines, and key players. "
                f"Provide data suitable for multiple visualization formats: charts, comparisons, "
                f"timelines, and processes. Be thorough but concise. Stay strictly on topic."
            ),
        },
        {
            "role": "user",
            "content": (
                f"Research ONLY this specific topic and provide a data-rich summary:\n\n"
                f"**{topic}**\n\n"
                f"CRITICAL INSTRUCTIONS:\n"
                f"1. Research EXCLUSIVELY about '{topic}' - nothing else\n"
                f"2. Ignore any web search results not directly related to '{topic}'\n"
                f"3. Do NOT include data about semiconductors, smartphones, grooming products, or other "
                f"unrelated industries unless they are directly part of '{topic}'\n"
                f"4. Search for the most recent data available as of {today}\n"
                f"5. Prefer sources published in {current_year}\n\n"
                f"For '{topic}' specifically, include:\n"
                f"- Key statistics with specific numbers, percentages, or dollar amounts\n"
                f"- Comparison data directly relevant to '{topic}'\n"
                f"- Timeline or chronological events related to '{topic}'\n"
                f"- Process or methodology descriptions relevant to '{topic}'\n"
                f"- Market sizes, growth rates, and future outlook for '{topic}'\n"
                f"- Major players/entities involved in '{topic}'\n\n"
                f"Structure the data so it can be visualized as bar charts, pie charts, "
                f"timelines, comparison tables, and process diagrams - all focused on '{topic}'."
            ),
        },
    ]
    return call_openrouter(messages, api_key, use_web_search=True, temperature=0.5)

Le code ci-dessus définit deux fonctions d’assistance clés qui constituent la couche de recherche du pipeline.

  • La fonction call_openrouter() sert d’interface pour envoyer des requêtes « chat » à OpenRouter avec le modèle Gemini 2.5 Flash. Elle construit la charge utile (modèle, messages, température, jetons) et gère l’authentification via la clé d’API.
  • Quand use_web_search=True, la fonction active le plugin web et injecte un prompt de recherche pour prioriser les informations les plus récentes. Après envoi, elle valide la réponse et en extrait le texte généré, évitant de poursuivre avec une sortie vide ou invalide.
  • La fonction research_topic() construit un prompt de recherche ciblé pour la génération d’infographies. Elle demande explicitement des éléments quantitatifs (statistiques, taux de croissance, tailles de marché, chronologies, comparaisons, acteurs clés) afin d’être visualisés ensuite en graphiques, tableaux ou schémas. Elle appelle call_openrouter() avec une température basse pour un résultat factuel et structuré.

Vous pouvez expérimenter la température, le nombre de résultats web ou le degré de stricte du brief de recherche pour ajuster l’équilibre entre largeur, fraîcheur et précision des données collectées.

Dans l’étape suivante, nous transformerons cette recherche brute en schéma de slides structuré (JSON) pour définir mise en page, indicateurs clés et éléments visuels de chaque slide avant l’envoi au modèle GLM-Image.

Étape 5 : phase 2 – convertir la recherche en JSON de slides structuré

Une fois la recherche prête, nous la transformons en plan structuré que GLM-Image pourra rendre de manière cohérente. Plutôt que de passer des paragraphes, nous produisons un plan JSON strict où chaque slide spécifie sa mise en page, ses textes clés et, si besoin, ses données de graphique.

def structure_slides(topic: str, research: str, num_slides: int, api_key: str) -> List[Dict]:
    system_prompt = f"""You create structured infographic slide decks. Return ONLY a valid JSON array with exactly {num_slides} slide objects. No markdown backticks. No explanation. Just the JSON.
SLIDE FORMATS:
Slide 1 — Title:
{{"type":"title","slide_number":1,"headline":"Bold Title (max 8 words)","subheadline":"One-liner with a key stat","tag":"CATEGORY LABEL","visual_elements":"2-3 topic-relevant decorative motifs (e.g. globe icon, circuit pattern for tech topics)"}}
Slides 2 to {num_slides-1} — Content (pick ONE layout per slide):
Layout "stat" — Use when there is one standout number to feature:
{{"type":"content","slide_number":N,"layout":"stat","headline":"Section Title (max 6 words)","key_stat":"$4.2T or +340%","key_stat_label":"What the stat means","bullets":["3-4 bullets with specific numbers"],"visual_elements":"2-3 topic-relevant icons","chart_data":{{"type":"bar|pie|line","title":"Chart title","data":[{{"label":"Category","value":"123"}}]}},"bottom_note":"Source"}}
Layout "comparison" — Use for side-by-side comparisons:
{{"type":"content","slide_number":N,"layout":"comparison","headline":"Section Title","items":[{{"label":"Item A","points":["fact 1 with number","fact 2 with number"]}},{{"label":"Item B","points":["fact 1","fact 2"]}}],"visual_elements":"topic-relevant icons","bottom_note":"Source"}}
Layout "timeline" — Use for chronological progressions:
{{"type":"content","slide_number":N,"layout":"timeline","headline":"Section Title","milestones":[{{"date":"2020","event":"Event description with metric"}},{{"date":"2023","event":"Another event"}}],"visual_elements":"topic-relevant icons","bottom_note":"Source"}}
Layout "process" — Use for step-by-step flows:
{{"type":"content","slide_number":N,"layout":"process","headline":"Section Title","steps":[{{"step":"1","title":"Step Name","detail":"Description with metric"}},{{"step":"2","title":"Next Step","detail":"Description"}}],"visual_elements":"topic-relevant icons","bottom_note":"Source"}}
Layout "list" — Use for data-rich bullets without a dominant stat:
{{"type":"content","slide_number":N,"layout":"list","headline":"Section Title","bullets":["4-5 bullets each with a specific number/metric"],"visual_elements":"topic-relevant icons","chart_data":{{"type":"bar|pie|line","title":"Chart title","data":[{{"label":"Category","value":"123"}}]}},"bottom_note":"Source"}}
Slide {num_slides} — Summary:
{{"type":"summary","slide_number":{num_slides},"headline":"Key Takeaways","takeaways":["4-5 punchy takeaway lines with data"],"outlook":"One sentence future outlook","visual_elements":"topic-relevant closing motif"}}
RULES:
- Every bullet MUST contain a specific number, percentage, or dollar amount
- Use a MIX of layouts across the deck. Do NOT use "stat" for every slide. Choose the layout that best fits the content. At most half of content slides should use "stat" layout.
- key_stat is ONLY used with "stat" layout. Other layouts do NOT have key_stat.
- visual_elements MUST be topic-specific (e.g. for EV topic: "electric car silhouette, battery icon, charging station"; for AI topic: "neural network diagram, robot icon, chip illustration"; for climate: "globe, thermometer, polar bear"). NEVER use generic shapes.
- chart_data is OPTIONAL. Include it when the slide's data naturally fits a chart. Fields:
  * type: "bar", "pie", or "line"
  * title: short chart title
  * data: array of {{"label":"...","value":"..."}} with real numbers from the research
  * axis_label: (optional) label for the value axis
  Not every slide needs chart_data. Use it for stat and list layouts when data is quantitative.
- Each slide covers a distinct aspect — no repetition
- Ensure logical narrative flow across slides"""
    messages = [
        {"role": "system", "content": system_prompt},
        {
            "role": "user",
            "content": (
                f"Create a {num_slides}-slide infographic deck about:\n\n"
                f"**{topic}**\n\n"
                f"RESEARCH DATA:\n{research}\n\n"
                f"Return ONLY the JSON array."
            ),
        },
    ]
    raw = call_openrouter(messages, api_key, use_web_search=False, temperature=0.2)
    raw = re.sub(r"^```(?:json)?\s*", "", raw.strip())
    raw = re.sub(r"\s*```$", "", raw.strip())
    return json.loads(raw)
def _build_chart_instruction(chart_data: Dict, accent_hex: str) -> str:
    if not chart_data or not chart_data.get("type"):
        return ""
    chart_type = chart_data["type"]
    title = chart_data.get("title", "Chart")
    data = chart_data.get("data", [])
    axis_label = chart_data.get("axis_label", "")
    if not data:
        return ""
    if chart_type == "bar":
        bars = ", ".join([f"'{d['label']}': {d['value']}" for d in data])
        axis_part = f" Y-axis label: '{axis_label}'." if axis_label else ""
        return (
            f"Draw a vertical bar chart titled '{title}' in the lower-right area of the slide, "
            f"taking up about 40% of the slide width. "
            f"Bars: [{bars}]. Label each bar clearly below it with its category name. "
            f"Display the value on top of each bar.{axis_part} "
            f"Use {accent_hex} color for the bars with slight shade variation between them. "
        )
    elif chart_type == "pie":
        segments = ", ".join([f"'{d['label']}': {d['value']}%" for d in data])
        return (
            f"Draw a pie chart titled '{title}' in the lower-right area of the slide, "
            f"taking up about 35% of the slide width. "
            f"Segments: [{segments}]. Label each segment with its name and percentage value. "
            f"Use {accent_hex} as the primary segment color with contrasting shades for other segments. "
        )
    elif chart_type == "line":
        points = ", ".join([f"({d['label']}, {d['value']})" for d in data])
        axis_part = f" Y-axis: '{axis_label}'." if axis_label else ""
        return (
            f"Draw a line chart titled '{title}' in the lower-right area of the slide, "
            f"taking up about 40% of the slide width. "
            f"Data points: [{points}]. Mark each data point with a dot and label its value. "
            f"Connect points with a smooth line in {accent_hex} color.{axis_part} "
        )
    elif chart_type == "table":
        rows = " | ".join([f"{d['label']}: {d['value']}" for d in data])
        return (
            f"Draw a data table titled '{title}' in the lower portion of the slide. "
            f"Data: [{rows}]. Use alternating row shading with {accent_hex} colored header row. "
            f"Keep text small but legible. "
        )
    return ""

En phase 2, nous définissons deux fonctions principales : l’une pour structurer les slides, l’autre pour générer des instructions de graphiques ou de tableaux. En particulier :

  • Fonction structure_slides() : elle utilise Gemini (sans recherche web) comme moteur de structuration. Elle construit un system prompt qui force le modèle à renvoyer uniquement un tableau JSON valide, avec exactement le nombre de slides demandé. Chaque deck suit un schéma narratif fixe : une page de titre, plusieurs slides de contenu, puis une synthèse. Les slides de contenu peuvent adopter différentes mises en page (stat, comparaison, chronologie, processus, liste) et le prompt impose des contraintes importantes :
  • Chaque puce doit comporter une mesure concrète (nombre, pourcentage, montant)
  • Varier les mises en page pour éviter la répétition
  • Les éléments visuels doivent être spécifiques au sujet
  • Inclure chart_data en option quand le contenu s’y prête naturellement
  • La fonction appelle ensuite call_openrouter() avec une température basse pour un rendu stable, supprime d’éventuels wrappers markdown et parse la réponse en dictionnaire Python pour la suite.
  • Fonction _build_chart_instruction() : elle convertit l’éventuel chart_data du JSON en instructions de dessin pour GLM-Image. Selon le type (barres, camembert, courbe ou tableau), elle génère des consignes claires de placement, étiquetage, dimensionnement et couleurs d’après la teinte d’accent du thème.

Au terme de cette étape, nous disposons d’une spécification propre au niveau du slide, qui définit à la fois le fil narratif et la structure visuelle du deck.

Étape 6 : convertir le JSON des slides en prompts GLM-Image

Nous traduisons désormais chaque objet slide en un prompt très contraint, indiquant au modèle quoi dessiner, où le placer et comment le styliser.

def build_glm_prompt(slide: Dict, theme: Dict, total: int, topic: str) -> str:
    style = theme["style"]
    colors = theme["colors"]
    bg_hex = colors["bg"]
    accent_hex = colors["accent"]
    text_hex = colors["text"]
    bg_anchor = (
        f"BACKGROUND: Use a solid, uniform {bg_hex} background across the entire image. "
        f"Do not add gradients, varying tones, or alternate background colors. "
        f"The background must be exactly {bg_hex}. "
    )
    common_footer = (
        f"Typography hierarchy: Headlines in bold large font, subheadings in semibold medium font, "
        f"body text in regular smaller font, footnotes in small font. "
        f"All text must be {text_hex} color unless it is an accent element using {accent_hex}. "
        f"In the bottom-right corner, display '{slide.get('slide_number', 1)} / {total}' in small text using {text_hex} at 50% opacity. "
        f"The overall design should be a single cohesive infographic slide image, "
        f"high resolution, crisp text rendering, professional quality. "
        f"All text in the image must be clearly legible and accurately spelled."
    )
    visual_elements = slide.get("visual_elements", "")
    visual_elements_instruction = (
        f"Include these topic-relevant visual elements as subtle icons or illustrations: {visual_elements}. "
        if visual_elements else ""
    )
    if slide["type"] == "title":
        return (
            f"{bg_anchor}"
            f"Create a professional infographic title slide. {style}. "
            f"Display these text elements in the image:\n"
            f"- Small uppercase tag at the top-center: '{slide.get('tag', 'REPORT')}'\n"
            f"- Large bold headline centered in the middle of the slide: '{slide['headline']}'\n"
            f"- Smaller subtitle centered below the headline: '{slide['subheadline']}'\n\n"
            f"The headline should be the dominant visual element. "
            f"{visual_elements_instruction}"
            f"{common_footer}"
        )
    elif slide["type"] == "summary":
        items = "\n".join(
            [f"  {i+1}. '{t}'" for i, t in enumerate(slide.get("takeaways", []))]
        )
        return (
            f"{bg_anchor}"
            f"Create a professional infographic summary slide. {style}. "
            f"Display these text elements in the image:\n"
            f"- Header at top-left: '{slide['headline']}'\n"
            f"- Numbered takeaway list in the center area:\n{items}\n"
            f"- Italic footer outlook at bottom-left: '{slide.get('outlook', '')}'\n\n"
            f"Use numbered circles in {accent_hex} color or checkmarks for each takeaway. "
            f"Make the layout clean and conclusive. "
            f"{visual_elements_instruction}"
            f"{common_footer}"
        )
    else: 
        layout = slide.get("layout", "stat")
        chart_instruction = _build_chart_instruction(slide.get("chart_data", {}), accent_hex)
        if layout == "stat":
            bullets_text = "\n".join([f"  • '{b}'" for b in slide.get("bullets", [])])
            has_chart = bool(chart_instruction)
            bullet_position = "in the left half of the slide" if has_chart else "below the statistic"
            return (
                f"{bg_anchor}"
                f"Create a professional infographic content slide. {style}. "
                f"Display these text elements in the image:\n"
                f"- Section header at top-left: '{slide['headline']}'\n"
                f"- One large featured statistic displayed very prominently in the center: '{slide.get('key_stat', '')}'\n"
                f"- Label below the stat in smaller text: '{slide.get('key_stat_label', '')}'\n"
                f"- Bullet points with data {bullet_position}:\n{bullets_text}\n"
                f"- Footer at bottom: '{slide.get('bottom_note', '')}'\n\n"
                f"The key statistic should be the most visually dominant element — "
                f"displayed in a very large, bold font in {accent_hex} color inside a highlight box. "
                f"Bullets should use small {accent_hex} colored icons or dot indicators. "
                f"{chart_instruction}"
                f"{visual_elements_instruction}"
                f"{common_footer}"
            )
        elif layout == "comparison":
            items = slide.get("items", [])
            items_text = ""
            for idx, item in enumerate(items):
                label = item.get("label", f"Item {idx+1}")
                points = item.get("points", [])
                points_text = "\n".join([f"    - '{p}'" for p in points])
                col_pos = "left column" if idx == 0 else "right column"
                items_text += f"  [{col_pos}] '{label}':\n{points_text}\n"
            return (
                f"{bg_anchor}"
                f"Create a professional infographic comparison slide. {style}. "
                f"Display these text elements in the image:\n"
                f"- Section header at top-left: '{slide['headline']}'\n"
                f"- Side-by-side comparison layout with a vertical divider line in {accent_hex}:\n{items_text}"
                f"- Footer at bottom: '{slide.get('bottom_note', '')}'\n\n"
                f"Use a clean two-column layout. Each column header should be bold and in {accent_hex}. "
                f"Bullet points under each column should have small icons. "
                f"Add a 'VS' or divider element between the columns. "
                f"{visual_elements_instruction}"
                f"{common_footer}"
            )
        elif layout == "timeline":
            milestones = slide.get("milestones", [])
            milestones_text = "\n".join(
                [f"  [{m.get('date', '')}] → '{m.get('event', '')}'" for m in milestones]
            )
            return (
                f"{bg_anchor}"
                f"Create a professional infographic timeline slide. {style}. "
                f"Display these text elements in the image:\n"
                f"- Section header at top-left: '{slide['headline']}'\n"
                f"- Horizontal timeline with these milestones connected by a line:\n{milestones_text}\n"
                f"- Footer at bottom: '{slide.get('bottom_note', '')}'\n\n"
                f"Draw a horizontal timeline line in {accent_hex} running across the center of the slide. "
                f"Place milestone dates above the line and event descriptions below, connected by vertical dots. "
                f"Each milestone dot should be a circle in {accent_hex}. "
                f"{visual_elements_instruction}"
                f"{common_footer}"
            )
        elif layout == "process":
            steps = slide.get("steps", [])
            steps_text = "\n".join(
                [f"  Step {s.get('step', idx+1)}: '{s.get('title', '')}' — {s.get('detail', '')}" for idx, s in enumerate(steps)]
            )
            return (
                f"{bg_anchor}"
                f"Create a professional infographic process flow slide. {style}. "
                f"Display these text elements in the image:\n"
                f"- Section header at top-left: '{slide['headline']}'\n"
                f"- Process flow with numbered steps connected by arrows:\n{steps_text}\n"
                f"- Footer at bottom: '{slide.get('bottom_note', '')}'\n\n"
                f"Arrange steps horizontally or in a flow diagram with arrows between them. "
                f"Each step should have a numbered circle in {accent_hex} with the step title in bold below it "
                f"and the detail text in smaller font underneath. "
                f"Connect steps with directional arrows in {accent_hex}. "
                f"{visual_elements_instruction}"
                f"{common_footer}"
            )
        else:  
            bullets_text = "\n".join([f"  • '{b}'" for b in slide.get("bullets", [])])
            has_chart = bool(chart_instruction)
            bullet_position = "in the left half of the slide" if has_chart else "in the center area"
            return (
                f"{bg_anchor}"
                f"Create a professional infographic content slide. {style}. "
                f"Display these text elements in the image:\n"
                f"- Section header at top-left: '{slide['headline']}'\n"
                f"- Data-rich bullet points {bullet_position}:\n{bullets_text}\n"
                f"- Footer at bottom: '{slide.get('bottom_note', '')}'\n\n"
                f"Bullets should use small {accent_hex} colored icons or numbered indicators. "
                f"Focus on clean information hierarchy and data-rich visual presentation. "
                f"{chart_instruction}"
                f"{visual_elements_instruction}"
                f"{common_footer}"
            )

La fonction build_glm_prompt() convertit un objet JSON de slide en un prompt GLM-Image. Elle procède en trois couches :

  • Contrôle du thème et de l’arrière-plan : elle récupère le style et la palette du thème, puis applique un ancrage d’arrière-plan strict pour imposer une couleur unie, sans dégradés ni variations.
  • Règles communes de mise en page : un bloc d’instructions partagées fait respecter la hiérarchie du texte, des couleurs cohérentes et une qualité de rendu élevée. Il ajoute également un compteur N / total discret pour que chaque image s’inscrive dans un deck.
  • Structure spécifique au layout : la fonction adapte le prompt selon le type de slide :
    • Titre : le titre principal domine visuellement, avec un tag et un sous-titre apportant contexte et métrique clé.
    • Synthèse : les enseignements clés sont consolidés en points concis et percutants.
    • Slides de contenu : plusieurs layouts : stat pour une grande valeur clé et des puces, comparison en deux colonnes, timeline et process pour la temporalité, et list pour des puces riches en données, éventuellement avec un graphique.

Si chart_data est présent, la fonction _build_chart_instruction() le transforme en instructions de dessin explicites. Cela améliore la fiabilité car le modèle sait précisément quoi dessiner et où.

Étape 7 : générer les images avec GLM-Image

Dernière étape : rendre les prompts ci-dessus en images infographiques.

def generate_glm_image(prompt: str, api_key: str, size: str = "1728x960") -> Optional[Image.Image]:
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
    }
    payload = {
        "model": GLM_IMAGE_MODEL,
        "prompt": prompt,
        "size": size,
    }
    try:
        resp = requests.post(GLM_IMAGE_API_URL, headers=headers, json=payload, timeout=180)
        resp.raise_for_status()
        data = resp.json()
        url = data.get("data", [{}])[0].get("url")
        if not url:
            st.error(f"GLM-Image returned no URL. Response: {json.dumps(data)[:300]}")
            return None
        img_resp = requests.get(url, timeout=60)
        img_resp.raise_for_status()
        return Image.open(io.BytesIO(img_resp.content)).convert("RGB")
    except requests.exceptions.Timeout:
        st.error("GLM-Image request timed out (180s). The model may be under heavy load.")
        return None
    except requests.exceptions.HTTPError as e:
        st.error(f"GLM-Image HTTP error: {e} — {e.response.text[:300] if e.response else ''}")
        return None
    except Exception as e:
        st.error(f"GLM-Image error: {e}")
        return None

Voici comment la fonction ci-dessus gère l’intégralité du flux de génération d’images pour GLM-Image :

  • La fonction generate_glm_image() constitue le point d’entrée pour chaque slide. Elle prépare la requête avec l’identifiant du modèle glm-image, le prompt du slide (contenant déjà layout, texte, thème, consignes de graphique) et la résolution cible, par exemple 1728x960.
  • Elle envoie ensuite cette charge utile à l’endpoint d’image Z.ai via requests.post() avec un timeout étendu, la génération d’image étant plus longue que la génération de texte.
  • Une fois la requête réussie, elle extrait l’URL hébergée de l’image, télécharge les octets via requests.get(), valide la réponse et convertit le contenu en objet PIL.Image RGB.

Vous pouvez tester différentes résolutions pour explorer le compromis qualité–latence et comprendre l’impact de la taille de sortie sur le temps de génération et la netteté.

Étape 8 : construire l’interface Streamlit

Enfin, nous intégrons toutes les fonctions dans le flux Streamlit pour générer les slides séquentiellement, suivre la progression et empaqueter le deck final pour téléchargement.

def render_theme_preview(name: str, theme: Dict):
    c = theme["colors"]
    return (
        f'<div style="display:inline-flex;align-items:center;gap:6px;">'
        f'<span style="display:inline-block;width:14px;height:14px;border-radius:3px;background:{c["bg"]};border:1px solid #555;"></span>'
        f'<span style="display:inline-block;width:14px;height:14px;border-radius:3px;background:{c["accent"]};"></span>'
        f'<span style="display:inline-block;width:14px;height:14px;border-radius:3px;background:{c["text"]};border:1px solid #555;"></span>'
        f'<span style="">{name}</span>'
        f'</div>'
    )
def main():
    st.set_page_config(
        page_title="GLM-Image Infographic Generator",
        page_icon="📊",
        layout="wide",
        initial_sidebar_state="expanded",
    )
    st.markdown("""
    <style>
    @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap');
    @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&display=swap');
    .stApp {}
    .hero-banner {
        background: linear-gradient(135deg, #0F172A 0%, #1E293B 50%, #0F172A 100%);
        
        border-radius: 16px;
        
        border: 1px solid #334155;
        position: relative;
        overflow: hidden;
    }
    .hero-banner::before {
        content: '';
        position: absolute;
        top: -50%;
        right: -20%;
        width: 400px;
        height: 400px;
        background: radial-gradient(circle, rgba(56,189,248,0.08) 0%, transparent 70%);
        border-radius: 50%;
    }
    .hero-banner h1 {
        
        
        font-weight: 800;
        
        letter-spacing: -0.5px;
    }
    .hero-banner .subtitle {
        
        font-size: 0.95rem;
        
    }
    .pipeline-tag {
        display: inline-block;
        
        background: rgba(56,189,248,0.1);
        
        
        border-radius: 20px;
        
        font-weight: 600;
        
        letter-spacing: 0.3px;
        border: 1px solid rgba(56,189,248,0.2);
    }
    .phase-header {
        
        
        letter-spacing: 1px;
        
        text-transform: uppercase;
        
    }
    .cost-card {
        background: #1E293B;
        border: 1px solid #334155;
        border-radius: 10px;
        
        
    }
    .cost-card .total {
        
        font-weight: 800;
        
    }
    .slide-nav-btn {
        background: #1E293B;
        border: 1px solid #334155;
        
        
        border-radius: 8px;
        cursor: pointer;
        font-weight: 600;
    }
    #MainMenu {visibility: hidden;}
    footer {visibility: hidden;}
    </style>
    """, unsafe_allow_html=True)
    st.markdown("""
    <div class="hero-banner">
        <h1>GLM-Image Infographic Generator</h1>
        <p class="subtitle">Enter any topic → AI researches the web → GLM-Image generates a full infographic slide deck</p>
        <div class="pipeline-tag">GEMINI 2.5 FLASH and GLM-IMAGE </div>
    </div>
    """, unsafe_allow_html=True)
    with st.sidebar:
        openrouter_key = os.environ.get("OPENROUTER_API_KEY", "")
        zai_key = os.environ.get("ZAI_API_KEY", "")
        st.markdown("#### Visual Theme")
        theme_name = st.selectbox(
            "Select Theme",
            list(THEMES.keys()),
            index=0,
        )
        theme = THEMES[theme_name]
        st.caption(theme["desc"])
        c = theme["colors"]
        st.markdown(
            f'<div style="display:flex;gap:8px;">'
            f'<div style="flex:1;height:32px;border-radius:6px;background:{c["bg"]};border:1px solid #555;" title="Background"></div>'
            f'<div style="flex:1;height:32px;border-radius:6px;background:{c["accent"]};" title="Accent"></div>'
            f'<div style="flex:1;height:32px;border-radius:6px;background:{c["text"]};border:1px solid #555;" title="Text"></div>'
            f'</div>',
            unsafe_allow_html=True,
        )
        st.markdown("#### Slide Settings")
        resolution = st.selectbox("Resolution", list(RESOLUTIONS.keys()), index=0)
        num_slides = st.slider("Number of Slides", 4, 10, 5)
        st.divider()
    if "selected_example" in st.session_state:
        default_topic = st.session_state.pop("selected_example")
    else:
        default_topic = ""
    topic = st.text_input(
        "Enter a topic to research and visualize",
        value=default_topic,
        placeholder="e.g. AI Industry Market Trends January 2026",
    )
    st.markdown("**Quick examples:**")
    cols = st.columns(3)
    for i, ex in enumerate(EXAMPLE_TOPICS):
        with cols[i % 3]:
            if st.button(ex, key=f"ex_{i}", use_container_width=True):
                st.session_state["selected_example"] = ex
                st.rerun()
    st.markdown("---")
    generate = st.button(
        "Generate Infographic Deck",
        type="primary",
        use_container_width=True,
        disabled=(not topic or not openrouter_key or not zai_key),
    )
    if generate:
        if not openrouter_key:
            st.error("Please enter your **OpenRouter API Key** in the sidebar.")
            st.stop()
        if not zai_key:
            st.error("Please enter your **Z.ai API Key** in the sidebar.")
            st.stop()
        size = RESOLUTIONS[resolution]
        st.markdown('<div class="phase-header">Phase 1 — Research & Structure</div>', unsafe_allow_html=True)
        with st.status("Researching topic with Gemini 2.5 Flash + Web Search...", expanded=True) as status:
            st.markdown(f"**Topic:** {topic}")
            st.markdown(f"**Model:** {OPENROUTER_MODEL}")
            t0 = time.time()
            try:
                research = research_topic(topic, openrouter_key)
                t_research = time.time() - t0
                st.success("Research complete in {:.1f}s".format(t_research))
                status.update(label="Research complete", state="complete")
            except Exception as e:
                st.error(f"Research failed: {e}")
                status.update(label="Research failed", state="error")
                st.stop()
        with st.status("Structuring slide content...", expanded=False) as status:
            try:
                slides_data = structure_slides(topic, research, num_slides, openrouter_key)
                st.success(f"Structured {len(slides_data)} slides")
                status.update(label=f"{len(slides_data)} slides structured", state="complete")
            except Exception as e:
                st.error(f"Structuring failed: {e}")
                status.update(label="Structuring failed", state="error")
                st.stop()
        with st.expander("Research Summary", expanded=False):
            st.markdown(research)
        with st.expander("Slide JSON", expanded=False):
            st.json(slides_data)
        st.markdown("---")
        st.markdown('<div class="phase-header">Phase 2 — GLM-Image Generation</div>', unsafe_allow_html=True)
        progress = st.progress(0, text="Starting image generation...")
        images: List[Optional[Image.Image]] = []
        prompts: List[str] = []
        timings: List[float] = []
        for i, slide in enumerate(slides_data):
            headline = slide.get("headline", f"Slide {i+1}")
            progress.progress(
                i / len(slides_data),
                text=f"Generating slide {i+1}/{len(slides_data)}: {headline}",
            )
            prompt = build_glm_prompt(slide, theme, len(slides_data), topic)
            prompts.append(prompt)
            t0 = time.time()
            img = generate_glm_image(prompt, zai_key, size)
            elapsed = time.time() - t0
            timings.append(elapsed)
            images.append(img)
            if img:
                st.toast("Slide {} generated ({:.1f}s)".format(i+1, elapsed))
            else:
                st.toast(f"Slide {i+1} failed", icon="")
        progress.progress(1.0, text="All slides generated!")
        time.sleep(0.3)
        progress.empty()
        st.session_state["images"] = images
        st.session_state["slides_data"] = slides_data
        st.session_state["prompts"] = prompts
        st.session_state["timings"] = timings
        st.session_state["research"] = research
        st.session_state["gen_topic"] = topic
        st.session_state["gen_theme"] = theme_name
    if "images" in st.session_state and st.session_state["images"]:
        images = st.session_state["images"]
        slides_data = st.session_state["slides_data"]
        prompts = st.session_state.get("prompts", [])
        timings = st.session_state.get("timings", [])
        st.markdown("---")
        gen_topic = st.session_state.get("gen_topic", "")
        gen_theme = st.session_state.get("gen_theme", "")
        success_count = sum(1 for img in images if img is not None)
        st.markdown(
            f"### Infographic Deck: *{gen_topic}*\n"
            f"{success_count}/{len(images)} slides · {gen_theme} theme · "
            f"{list(RESOLUTIONS.keys())[0]} resolution"
        )
        slide_idx = st.select_slider(
            "Navigate Slides",
            options=list(range(1, len(images) + 1)),
            value=1,
            format_func=lambda x: f"Slide {x}: {slides_data[x-1].get('headline', '') if x-1 < len(slides_data) else ''}",
        )
        idx = slide_idx - 1
        col_img, col_meta = st.columns([3, 1])
        with col_img:
            if images[idx] is not None:
                st.image(images[idx], use_container_width=True)
            else:
                st.warning(
                    f"Slide {slide_idx} failed to generate. "
                    "Check your Z.ai API key and quota."
                )
        with col_meta:
            slide = slides_data[idx] if idx < len(slides_data) else {}
            st.markdown(f"**Slide {slide_idx} / {len(images)}**")
            slide_type = slide.get('type', 'N/A')
            layout = slide.get('layout', '')
            type_label = f"{slide_type}" + (f" · {layout}" if layout else "")
            st.markdown(f"**Type:** {type_label}")
            if slide.get("key_stat"):
                tc = THEMES.get(gen_theme, THEMES["Corporate Navy"])["colors"]
                st.markdown(
                    f'<div style="background:{tc["bg"]};border:2px solid {tc["accent"]}; '
                    f'border-radius:12px;text-align:center;">'
                    f'<div style="font-weight:800;color:{tc["accent"]};">'
                    f'{slide["key_stat"]}</div>'
                    f'<div style="color:{tc["text"]};opacity:0.7;">'
                    f'{slide.get("key_stat_label","")}</div></div>',
                    unsafe_allow_html=True,
                )
            if slide.get("bullets"):
                st.markdown("**Data Points:**")
                for b in slide["bullets"]:
                    st.markdown(f"• {b}")
            if slide.get("items"):
                st.markdown("**Comparison:**")
                for item in slide["items"]:
                    st.markdown(f"**{item.get('label', '')}**")
                    for p in item.get("points", []):
                        st.markdown(f"  • {p}")
            if slide.get("milestones"):
                st.markdown("**Timeline:**")
                for m in slide["milestones"]:
                    st.markdown(f"• **{m.get('date', '')}** — {m.get('event', '')}")
            if slide.get("steps"):
                st.markdown("**Process:**")
                for s in slide["steps"]:
                    st.markdown(f"{s.get('step', '')}. **{s.get('title', '')}** — {s.get('detail', '')}")
            if slide.get("takeaways"):
                st.markdown("**Takeaways:**")
                for i, t in enumerate(slide["takeaways"], 1):
                    st.markdown(f"{i}. {t}")
            if idx < len(timings):
                st.caption("Generated in {:.1f}s".format(timings[idx]))
        if idx < len(prompts):
            with st.expander(f"GLM-Image Prompt — Slide {slide_idx}"):
                st.code(prompts[idx], language="text")
        st.markdown("---")
        st.markdown("**All Slides:**")
        thumb_cols = st.columns(min(len(images), 8))
        for i, img in enumerate(images):
            with thumb_cols[i % len(thumb_cols)]:
                if img is not None:
                    st.image(img, caption=f"Slide {i+1}", use_container_width=True)
                else:
                    st.caption(f"Slide {i+1}")
        st.markdown("---")
        dl_col1, dl_col2, dl_col3, _ = st.columns([1, 1, 1, 1])
        with dl_col1:
            zip_buf = io.BytesIO()
            with zipfile.ZipFile(zip_buf, "w", zipfile.ZIP_DEFLATED) as zf:
                for i, img in enumerate(images):
                    if img is not None:
                        buf = io.BytesIO()
                        img.save(buf, format="PNG", quality=95)
                        zf.writestr(f"slide_{i+1:02d}.png", buf.getvalue())
                zf.writestr("slide_data.json", json.dumps(slides_data, indent=2, ensure_ascii=False))
                zf.writestr("prompts.json", json.dumps(prompts, indent=2, ensure_ascii=False))
            safe_name = re.sub(r"[^a-zA-Z0-9]+", "_", gen_topic)[:40]
            st.download_button(
                "Download ZIP (all slides)",
                data=zip_buf.getvalue(),
                file_name=f"infographic_{safe_name}.zip",
                mime="application/zip",
                use_container_width=True,
            )
        with dl_col2:
            st.download_button(
                "Download Slide JSON",
                data=json.dumps(slides_data, indent=2, ensure_ascii=False),
                file_name=f"slides_{safe_name}.json",
                mime="application/json",
                use_container_width=True,
            )
    elif "images" not in st.session_state:
        st.markdown(
            '<div style="text-align:center;">'
            '<div style="opacity:0.3;">📊</div>'
            '<p style="max-width:500px;">'
            "Enter any topic above, and the system will search the web for the latest data, "
            "structure it into slides, and generate a complete infographic deck using GLM-Image."
            "</p></div>",
            unsafe_allow_html=True,
        )
if __name__ == "__main__":
    main()

Le code ci-dessus définit l’application Streamlit complète, reliant toutes les étapes du pipeline dans un seul flux.

  • La fonction main() est le point d’entrée : configuration de la page, styles personnalisés et bannière. La barre latérale sert de panneau de configuration : sélection et prévisualisation du thème, choix de la résolution et du nombre de slides. Ces réglages influencent directement la construction des prompts et le rendu.
  • L’interface principale recueille le sujet et propose des exemples pour accélérer les tests. Le bouton Generate Infographic Deck vérifie la présence du sujet et des clés d’API avant de lancer l’exécution.
  • Une fois lancé, le flux se déroule en deux phases. D’abord, la recherche web puis la conversion en JSON structuré via le modèle de langue, avec indicateurs de progression et sections déroulantes pour inspecter la synthèse et la sortie structurée. Ensuite, l’app génère les images séquentiellement avec GLM-Image, en affichant une barre de progression, des temps par slide et des toasts, tout en stockant images, prompts et métadonnées dans st.session_state pour persister entre les relances.
  • Après génération, l’interface bascule en visionneuse de deck pour naviguer entre les slides, voir le détail structuré, inspecter le prompt exact et prévisualiser les miniatures.
  • Enfin, l’app regroupe tous les résultats (images, JSON des slides, prompts) dans un ZIP téléchargeable, et propose également des exports JSON séparés.

Pour essayer, enregistrez le code sous app.py et lancez :

Streamlit run app.py

image5.png

Conclusion

Dans ce tutoriel, nous avons construit un générateur d’infographies en temps réel : l’utilisateur saisit un sujet, Gemini 2.5 Flash effectue une recherche web et structure le contenu en plan de slides, puis GLM-Image rend chaque page en infographie haute résolution. La clé du succès : convertir l’information non structurée en spécification de rendu contrainte.

Des marges de progrès subsistent toutefois. GLM-Image peut parfois mal orthographier certains mots : une faiblesse fréquente des modèles de génération d’images lorsqu’ils produisent des textes longs et denses.

Pour aller plus loin vers la production, ajoutez la gestion des citations, des vérifications de répétition et une régénération au slide. Même en l’état, l’app illustre bien comment les modèles de génération d’images peuvent servir de moteurs de rendu visuel pour la data storytelling.

N’oubliez pas de consulter notre guide sur le nouveau modèle d’image de Google, Nano Banana 2 !

FAQ sur GLM Image

Ai-je besoin d’un GPU pour exécuter ce projet GLM Image ?

Non. L’application fonctionne entièrement via des API (OpenRouter pour le texte et GLM-Image pour les visuels), aucun GPU local n’est requis. Un ordinateur standard avec une connexion Internet stable suffit.

Combien coûte la génération d’un deck avec GLM Image ?

Les coûts dépendent du nombre de slides et de l’usage des API. Pour des exécutions typiques (4 – 8 slides), quelques dollars de crédit OpenRouter et Z.ai suffisent généralement. Environ 5 $ sur chaque compte permettent de multiples essais.

Puis-je utiliser un autre modèle de texte que Gemini 2.5 Flash ?

Oui. Comme le projet utilise OpenRouter, vous pouvez basculer vers d’autres modèles pris en charge comme OpenAI, Claude ou d’autres variantes de Gemini. Mettez simplement à jour le nom du modèle selon le fournisseur et le rapport coût/performance recherché.

Puis-je personnaliser le style visuel des slides ?

Oui. L’app propose la sélection de thème (couleurs d’arrière-plan, d’accent et du texte), les résolutions et le nombre de slides. Vous pouvez aussi étendre le dictionnaire de thèmes ou modifier les templates de prompts pour créer vos propres styles.

Puis-je utiliser Gemini directement au lieu d’OpenRouter ?

Oui. OpenRouter est utilisé ici pour la simplicité, mais vous pouvez appeler Gemini 2.5 Flash directement via Google AI Studio ou Vertex AI. Il suffit de remplacer l’endpoint OpenRouter par l’API officielle Gemini et de conserver la même structure de messages.


Aashi Dutt's photo
Author
Aashi Dutt
LinkedIn
Twitter

Je suis experte Google Developers en ML (Gen AI), triple experte Kaggle et ambassadrice Women Techmakers, avec plus de trois ans d’expérience dans la tech. J’ai cofondé une startup dans le domaine de la santé en 2020 et je poursuis actuellement un master en informatique à Georgia Tech, avec une spécialisation en apprentissage automatique.

Sujets
Intelligence artificielle
Grands modèles linguistiques

Meilleurs cours DataCamp

Cours

Concevoir des systèmes agentiques avec LangChain

3 h
14.5K
Découvrez les composants clés des agents LangChain et créez vos propres agents de chat personnalisés.
Afficher les détailsRight Arrow
Commencer Le Cours
Voir plusRight Arrow