commit a95f51dba38a13ee66fdbf6d8d981470ece4d185 Author: Master Date: Mon Jun 15 22:09:49 2026 +0200 Pipeline Sliding : archive v1 + pipeline v2 opérationnel (21 layouts) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b8a80bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +venv/ +__pycache__/ +*.pyc +projets/*/outputs/ +projets/*/inputs/ +*.pptx +*.pdf +*.bak +*.bak_* diff --git a/agent_constraints_v2.md b/agent_constraints_v2.md new file mode 100644 index 0000000..d56797c --- /dev/null +++ b/agent_constraints_v2.md @@ -0,0 +1,520 @@ +# AGENT CONSTRAINTS v2 — généré par prompt_injection_v2.py +# Design System « PR Editorial » — 21 layouts + +--- + +## LAYOUTS_CATALOGUE + +#### Ouverture + +`cover_split` (L01) — mode dark + → Slide de couverture. titre = titre de la présentation, sous_titre = tagline en une phrase. + +#### Synthèse + +`executive_summary` (L05) — mode light + → Synthèse SCR. Titre = So What. 3 cartes pleine largeur : situation, complication, resolution (1-2 phrases chacune). + +#### Structure + +`section_divider` (L03) — mode dark + → Transition de section. titre = nom de la section. Le numéro est incrémenté automatiquement par le moteur. + +#### Données + +`big_stat` (L10) — mode light + → Un chiffre héro plein écran. valeur = le chiffre (ex "78%"), description = ce qu'il signifie, source = référence. + +#### Comparaison + +`two_cols_text` (L07) — mode light + → Deux colonnes en cartes avec en-tête coloré. left/right = {titre, bullets[]}. Gauche = navy, droite = corail. + +#### Données + +`kpi_grid` (L09) — mode light + → 2 à 6 cartes KPI. items = [{label, valeur, description}]. valeur en grand corail, label en en-tête, description en source. + +#### Message + +`key_message` (L08) — mode dark + → Message clé en citation plein écran. message = la phrase forte, detail = sous-texte optionnel. + +#### Concept + +`circular_diagram` (L17) — mode light + → 3 à 6 valeurs en cercles + légende à droite. segments = [{label, description, couleur?}]. + +#### Texte structuré + +`default_bullets` (L06) — mode light + → Liste de points clés. Si texte au format "Mot : explication", le moteur met le mot en gras navy. Max 5 bullets. bullets = [{niveau, texte}]. + +#### Process + +`numbered_steps` (L19) — mode light + → Étapes en cartes pleine largeur avec badge rond numéroté. steps = [{numero, titre, description}]. + +#### Planning + +`phases_timeline` (L23) — mode light + → Phases reliées par une ligne pointillée. phases = [{label, periode}]. Couleurs du cycle theme. + +#### Acteurs & décision + +`recommendation_card` (L28) — mode panel + → Carte de recommandation. Sidebar = numero + titre + cta. Corps = headline + bullets. + +#### Clôture + +`end_slide` (L30) — mode dark + → Slide de fin. message = phrase de conclusion. + +#### Transformation + +`from_to_pairs` (L31) — mode light + → Transformation avant/après en paires alignées. pairs = [{from, to}]. label_from/label_to = en-têtes colonnes. Max 5 paires. Idéal pour tangibiliser un changement. + +#### Planning + +`gantt_timeline` (L32) — mode light + → Gantt par workstreams. periods = liste de labels (ex ["Juin","Juil"]). workstreams = [{label, tasks: [{label, start, end}]}]. start/end = index dans periods (0-based). Max 3 workstreams, 4 tâches chacun. + +#### Historique + +`yearly_timeline` (L33) — mode light + → Frise chronologique horizontale. milestones = [{annee, label, actif?}]. actif = true pour le jalon courant (corail). Labels alternent haut/bas. Max 6 jalons. + +#### Comparaison + +`comparison_table` (L34) — mode light + → Tableau structuré multi-critères. headers = liste de colonnes (1ère = critères). rows = [{label, values: []}] ou listes plates. Max 5 colonnes, 8 lignes. + +#### Gouvernance + +`raci_table` (L35) — mode light + → Matrice RACI. roles = liste de rôles. tasks = [{label, raci: [R/A/C/I]}]. R=Responsable(corail), A=Autorité(navy), C=Consulté(slate), I=Informé(muted). Max 4 rôles, 8 tâches. + +#### Process + +`process_arrow` (L36) — mode light + → Flux horizontal de 3 à 6 étapes. steps = [{titre, description}]. Badges numérotés corail, couleurs cycle alternées. Max 6 étapes. + +#### Organisation + +`org_chart` (L37) — mode light + → Organigramme hiérarchique max 3 niveaux. root = {label, children: [{label, children?: [{label}]}]}. Max 4 enfants directs, 3 petits-enfants par enfant. + +#### Priorisation + +`matrix_2x2` (L38) — mode light + → Matrice effort/impact. axis_x/axis_y = {label, low, high}. quadrants = {top_left, top_right, bottom_left, bottom_right}. items = [{label, x, y}] avec x/y de 0 à 100. Max 8 items. + +--- + +## SEQUENCING_RULES +### Règles de fluidité + +- Maximum 2 layouts texte consécutifs : `default_bullets`, `two_cols_text` +- Après 3 slides denses → intercaler une respiration : `big_stat`, `key_message`, `section_divider` +- Jamais 2 `section_divider` consécutifs +- Alterner les modes : un slide dark (`section_divider`, `key_message`) toutes les 3-5 slides claires donne le rythme "sandwich" du design system +- Le dernier slide de contenu avant `end_slide` doit être narratif (`recommendation_card` ou `key_message`) + +### Règles de choix de layout + +- 1 seul chiffre décisif → `big_stat` (jamais `kpi_grid` avec 1 item) +- 2 à 6 indicateurs chiffrés → `kpi_grid` +- Comparaison ou opposition (avant/après, attentes/réalité, risques/solutions) → `two_cols_text` +- 2 à 5 étapes séquentielles → `numbered_steps` +- Phases avec périodes temporelles (mois, trimestres) → `phases_timeline` +- 3 à 6 concepts ou valeurs formant un système → `circular_diagram` +- Recommandation unique et actionnable → `recommendation_card` +- Citation ou message à marteler seul → `key_message` +- Transformation avant/après → `from_to_pairs` (plus parlant que two_cols_text) +- Planning projet avec tâches datées → `gantt_timeline` +- Frise chronologique d'historique ou de jalons annuels → `yearly_timeline` +- Tableau comparatif multi-critères → `comparison_table` +- Responsabilités par rôle → `raci_table` +- Flux séquentiel de 3 à 6 étapes (sans dates) → `process_arrow` +- Organigramme hiérarchique → `org_chart` +- Priorisation effort/impact → `matrix_2x2` +- Si le contenu ne rentre dans aucun layout spécialisé → `default_bullets` + +### Règles de quantité + +- Présentation 20 min → 10 à 15 slides max +- Présentation 10 min → 6 à 10 slides max +- Fusionner si > 15 slides : regrouper les slides proches thématiquement +- `default_bullets` : 5 bullets maximum — privilégier le format "Mot-clé : explication" +- Un `###` du Markdown avec un seul chiffre fort → envisager `big_stat` séparé +- Un `##` du Markdown = 1 `section_divider` (sauf présentation < 6 slides) + +--- + +## NARRATIVE_PATTERNS +### Pattern "Problem → Solution → Proof" +Adapté aux présentations de recommandation stratégique. +``` +cover_split +executive_summary (synthèse SCR dès le début) +section_divider ("Le problème") +big_stat (chiffre choc) +two_cols_text ou default_bullets +section_divider ("Notre réponse") +numbered_steps +kpi_grid (preuve chiffrée) +recommendation_card (ce qu'on demande) +end_slide +``` + +### Pattern "Roadmap Deck" +Adapté aux présentations de planification / lancement de projet. +``` +cover_split +executive_summary (où on va et pourquoi) +kpi_grid (état des lieux chiffré) +phases_timeline (le calendrier) +numbered_steps (comment on s'organise) +recommendation_card (décisions à prendre) +end_slide +``` + +### Pattern "Executive Briefing" +Adapté aux présentations courtes (< 10 slides) pour un CODIR. +``` +cover_split +executive_summary +key_message (le So What en 1 slide) +kpi_grid ou big_stat +recommendation_card +end_slide +``` + +### Pattern "Vision & Valeurs" +Adapté aux présentations de transformation ou de culture. +``` +cover_split +executive_summary +section_divider +circular_diagram (le système de valeurs/concepts) +default_bullets (le cadre, les principes) +key_message (la conviction) +recommendation_card +end_slide +``` + +### Règles d'assemblage des patterns + +- Les patterns sont des points de départ, pas des contraintes rigides +- Hybrider 2 patterns est possible si le contenu le justifie +- Toujours préserver : cover_split en premier, end_slide en dernier +- Les section_dividers sont optionnels pour les patterns courts (< 8 slides) + +--- + +## YAML_SCHEMAS + +### `cover_split` (L01) +**Requis :** titre +**Optionnels :** sous_titre +**Exemple :** +```yaml +layout: cover_split +titre: "Titre de la présentation" +sous_titre: "Tagline en une phrase" +``` + +### `executive_summary` (L05) +**Requis :** titre, situation, complication, resolution +**Exemple :** +```yaml +layout: executive_summary +titre: "Le So What en titre affirmatif" +situation: "État des lieux factuel..." +complication: "Le problème ou la tension..." +resolution: "La réponse proposée..." +``` + +### `section_divider` (L03) +**Requis :** titre +**Exemple :** +```yaml +layout: section_divider +titre: "Nom de la section" +# le numéro de section est généré automatiquement +``` + +### `big_stat` (L10) +**Requis :** titre, valeur +**Optionnels :** description, source +**Exemple :** +```yaml +layout: big_stat +titre: "Titre affirmatif" +valeur: "78%" +description: "ce que le chiffre signifie" +source: "Référence — 2026" +``` + +### `two_cols_text` (L07) +**Requis :** titre, left, right +**Exemple :** +```yaml +layout: two_cols_text +titre: "Titre affirmatif" +left: + titre: "Titre colonne gauche" + bullets: + - texte: "Premier point" + - texte: "Deuxième point" +right: + titre: "Titre colonne droite" + bullets: + - texte: "Premier point" +``` + +### `kpi_grid` (L09) +**Requis :** titre, items +**Exemple :** +```yaml +layout: kpi_grid +titre: "Titre affirmatif" +items: + - label: "Indicateur 1" + valeur: "+13%" + description: "Source ou contexte de la valeur" + - label: "Indicateur 2" + valeur: "85%" + description: "Source ou contexte" +``` + +### `key_message` (L08) +**Requis :** message +**Optionnels :** detail +**Exemple :** +```yaml +layout: key_message +message: "Le message clé en une phrase forte." +detail: "Sous-texte optionnel en une ligne." +``` + +### `circular_diagram` (L17) +**Requis :** titre, segments +**Exemple :** +```yaml +layout: circular_diagram +titre: "Titre affirmatif" +segments: + - label: "Concept 1" + description: "Description courte" + - label: "Concept 2" + description: "Description courte" + - label: "Concept 3" + description: "Description courte" +``` + +### `default_bullets` (L06) +**Requis :** titre, bullets +**Exemple :** +```yaml +layout: default_bullets +titre: "Titre affirmatif" +bullets: + - texte: "Mot-clé : explication du point" + niveau: 1 + - texte: "Autre mot-clé : explication" + niveau: 1 +``` + +### `numbered_steps` (L19) +**Requis :** titre, steps +**Exemple :** +```yaml +layout: numbered_steps +titre: "Titre affirmatif" +steps: + - numero: 1 + titre: "Première étape" + description: "Ce que ça implique concrètement" + - numero: 2 + titre: "Deuxième étape" + description: "Ce que ça implique" +``` + +### `phases_timeline` (L23) +**Requis :** titre, phases +**Exemple :** +```yaml +layout: phases_timeline +titre: "Titre affirmatif" +phases: + - label: "Mois 1-2" + periode: "Ce qui se passe dans cette phase" + - label: "Mois 3-8" + periode: "Ce qui se passe" +``` + +### `recommendation_card` (L28) +**Requis :** titre, headline, bullets +**Optionnels :** numero, cta +**Exemple :** +```yaml +layout: recommendation_card +numero: 1 +titre: "Nom court de la recommandation" +headline: "TROIS DÉCISIONS À PRENDRE MAINTENANT" +cta: "Décider en réunion du 30 juin" +bullets: + - texte: "Première décision précise" + - texte: "Deuxième décision précise" +``` + +### `end_slide` (L30) +**Requis :** message +**Exemple :** +```yaml +layout: end_slide +message: "Phrase de conclusion mémorable" +``` + +### `from_to_pairs` (L31) +**Requis :** titre, pairs +**Optionnels :** label_from, label_to +**Exemple :** +```yaml +layout: from_to_pairs +titre: "Titre affirmatif" +label_from: "SITUATION ACTUELLE" +label_to: "SITUATION CIBLE" +pairs: + - from: "État de départ" + to: "État cible" + - from: "Autre point de départ" + to: "Autre cible" +``` + +### `gantt_timeline` (L32) +**Requis :** titre, periods, workstreams +**Exemple :** +```yaml +layout: gantt_timeline +titre: "Titre affirmatif" +periods: ["Juin", "Juil", "Août", "Sept"] +workstreams: + - label: "Workstream 1" + tasks: + - label: "Tâche A" + start: 0 + end: 2 + - label: "Workstream 2" + tasks: + - label: "Tâche B" + start: 1 + end: 3 +``` + +### `yearly_timeline` (L33) +**Requis :** titre, milestones +**Exemple :** +```yaml +layout: yearly_timeline +titre: "Titre affirmatif" +milestones: + - annee: "2024" + label: "Premier jalon" + - annee: "2025" + label: "Deuxième jalon" + - annee: "2026" + label: "Jalon courant" + actif: true +``` + +### `comparison_table` (L34) +**Requis :** titre, headers, rows +**Exemple :** +```yaml +layout: comparison_table +titre: "Titre affirmatif" +headers: ["Critère", "Option A", "Option B"] +rows: + - label: "Premier critère" + values: ["Valeur A", "Valeur B"] + - label: "Deuxième critère" + values: ["Valeur A", "Valeur B"] +``` + +### `raci_table` (L35) +**Requis :** titre, roles, tasks +**Exemple :** +```yaml +layout: raci_table +titre: "Titre affirmatif" +roles: ["Rôle 1", "Rôle 2", "Rôle 3"] +tasks: + - label: "Première activité" + raci: ["A", "R", "C"] + - label: "Deuxième activité" + raci: ["A", "I", "R"] +``` + +### `process_arrow` (L36) +**Requis :** titre, steps +**Exemple :** +```yaml +layout: process_arrow +titre: "Titre affirmatif" +steps: + - titre: "Étape 1" + description: "Ce qui se passe" + - titre: "Étape 2" + description: "Ce qui se passe" + - titre: "Étape 3" + description: "Ce qui se passe" +``` + +### `org_chart` (L37) +**Requis :** titre, root +**Exemple :** +```yaml +layout: org_chart +titre: "Titre affirmatif" +root: + label: "Responsable racine" + children: + - label: "Manager 1" + children: + - label: "Équipe A" + - label: "Manager 2" + children: + - label: "Équipe B" +``` + +### `matrix_2x2` (L38) +**Requis :** titre, items +**Optionnels :** axis_x, axis_y, quadrants +**Exemple :** +```yaml +layout: matrix_2x2 +titre: "Titre affirmatif" +axis_x: + label: "Effort" + low: "Faible" + high: "Élevé" +axis_y: + label: "Impact" + low: "Faible" + high: "Élevé" +quadrants: + top_left: "Gains rapides" + top_right: "Projets stratégiques" + bottom_left: "Déprioritiser" + bottom_right: "À planifier" +items: + - label: "Initiative A" + x: 30 + y: 80 + - label: "Initiative B" + x: 70 + y: 75 +``` \ No newline at end of file diff --git a/archive/facilitator_v1.0.py b/archive/facilitator_v1.0.py new file mode 100644 index 0000000..34be07f --- /dev/null +++ b/archive/facilitator_v1.0.py @@ -0,0 +1,613 @@ +#!/usr/bin/env python3 +""" +facilitator.py — Sliding Pipeline v3 · Pernod Ricard +===================================================== +Orchestre les 3 agents du nouveau pipeline : + + The Narrator (Agent 1) → Markdown narratif + ↓ approbation Bastien + The Designer (Agent 2) → Plan de présentation texte + ↓ approbation optionnelle + The Encoder (Agent 3) → YAML valide + ↓ + render_engine.py → PPTX + +Variables .env requises : + MISTRAL_API_KEY + NARRATOR_AGENT_ID + DESIGNER_AGENT_ID + ENCODER_AGENT_ID + OUTPUT_DIR (dossier de sortie des YAML/PPTX, défaut : ./output) + THEME_PATH (défaut : theme.yaml) + COMPONENTS_PATH (défaut : components.yaml) + LAYOUTS_PATH (défaut : layouts.yaml) +""" + +import json +import os +import re +import sys +import textwrap +from datetime import datetime +from pathlib import Path + +import requests +import yaml +from dotenv import load_dotenv +from typing import Optional, Tuple, Dict, Union, List + +load_dotenv() + +# ───────────────────────────────────────────────────────────────────────────── +# CONFIGURATION +# ───────────────────────────────────────────────────────────────────────────── + +API_KEY = os.getenv("MISTRAL_API_KEY") +NARRATOR_ID = os.getenv("NARRATOR_AGENT_ID") +DESIGNER_ID = os.getenv("DESIGNER_AGENT_ID") +ENCODER_ID = os.getenv("ENCODER_AGENT_ID") +OUTPUT_DIR = Path(os.getenv("OUTPUT_DIR", "./output")) +THEME_PATH = os.getenv("THEME_PATH", "theme.yaml") +COMPONENTS_PATH = os.getenv("COMPONENTS_PATH", "components.yaml") +LAYOUTS_PATH = os.getenv("LAYOUTS_PATH", "layouts.yaml") + +HEADERS = { + "Authorization": f"Bearer {API_KEY}", + "Content-Type": "application/json", +} + +VERSION = "3.0" + +OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + +# ───────────────────────────────────────────────────────────────────────────── +# UTILITAIRES D'AFFICHAGE +# ───────────────────────────────────────────────────────────────────────────── + +def banner(): + print("\n" + "═" * 62) + print(f" SLIDING PIPELINE v{VERSION} — Pernod Ricard") + print(f" {datetime.now().strftime('%d/%m/%Y %H:%M')}") + print("═" * 62 + "\n") + + +def section(title: str): + print(f"\n{'─' * 62}") + print(f" {title}") + print(f"{'─' * 62}\n") + + +def info(msg: str): + print(f" ℹ {msg}") + + +def ok(msg: str): + print(f" ✓ {msg}") + + +def warn(msg: str): + print(f" ⚠ {msg}") + + +def ask(prompt: str) -> str: + try: + return input(f"\n {prompt} ").strip() + except (EOFError, KeyboardInterrupt): + print("\n Session interrompue.") + sys.exit(0) + + +def display_markdown(text: str, max_lines: int = 60): + """Affiche le Markdown avec un max de lignes.""" + lines = text.splitlines() + if len(lines) <= max_lines: + print(textwrap.indent(text, " ")) + else: + preview = "\n".join(lines[:max_lines]) + print(textwrap.indent(preview, " ")) + print(f"\n ... [{len(lines) - max_lines} lignes supplémentaires — " + f"choisissez [4] pour tout voir]") + + +def display_plan(text: str, max_lines: int = 80): + """Affiche le plan du Designer — extrait les lignes SLIDE pour un résumé rapide.""" + slides = [l for l in text.splitlines() if l.strip().startswith("SLIDE")] + if slides: + print(" Résumé du plan :\n") + for s in slides: + print(f" {s.strip()}") + print(f"\n ({len(slides)} slides au total)") + else: + display_markdown(text, max_lines) + + +# ───────────────────────────────────────────────────────────────────────────── +# APPEL AGENT MISTRAL (avec gestion PAUSE/continue) +# ───────────────────────────────────────────────────────────────────────────── + +def call_agent(agent_id: str, messages: list) -> str: + """ + Appelle un agent Mistral et gère la pagination PAUSE/continue. + Retourne la réponse complète concaténée. + """ + full_response = "" + current_messages = messages.copy() + + while True: + resp = requests.post( + "https://api.mistral.ai/v1/agents/completions", + headers=HEADERS, + json={"agent_id": agent_id, "messages": current_messages}, + timeout=120, + ) + resp.raise_for_status() + content = resp.json()["choices"][0]["message"]["content"] + full_response += content + + # Détection pagination + if "PAUSE" in content and "FIN —" not in content: + info("Pagination détectée → continuation automatique...") + current_messages.append({"role": "assistant", "content": content}) + current_messages.append({"role": "user", "content": "continue"}) + else: + break + + return full_response + + +def call_with_feedback(agent_id: str, initial_message: str, + feedback: str, previous_output: str) -> str: + """Relance un agent avec le feedback utilisateur et la sortie précédente.""" + messages = [ + {"role": "user", "content": initial_message}, + {"role": "assistant", "content": previous_output}, + {"role": "user", "content": feedback}, + ] + return call_agent(agent_id, messages) + + +# ───────────────────────────────────────────────────────────────────────────── +# VALIDATION YAML (The Encoder) +# ───────────────────────────────────────────────────────────────────────────── + +def extract_yaml(raw: str) -> str: + """ + Extrait le bloc YAML d'une réponse (enlève les ```yaml ... ``` si présents). + """ + # Bloc markdown yaml + match = re.search(r"```ya?ml\s*(.*?)```", raw, re.DOTALL | re.IGNORECASE) + if match: + return match.group(1).strip() + # Pas de bloc → retourne tel quel + return raw.strip() + + +def validate_yaml(raw: str, layouts: dict) -> tuple[bool, str, Optional[Dict]]: + """ + Valide la syntaxe YAML et vérifie les contraintes de base. + Retourne (is_valid, message_erreur, data_parsée). + """ + try: + content = extract_yaml(raw) + data = yaml.safe_load(content) + except yaml.YAMLError as e: + return False, f"Erreur de syntaxe YAML : {e}", None + + if not isinstance(data, dict): + return False, "Le YAML doit être un dictionnaire à la racine.", None + + slides = data.get("slides") + if not slides: + return False, "Clé 'slides' manquante ou vide.", None + + errors = [] + valid_layouts = set(layouts.keys()) + + for i, slide in enumerate(slides): + pos = slide.get("position", i + 1) + layout = slide.get("layout", "") + + if not layout: + errors.append(f"Slide {pos} : clé 'layout' manquante") + continue + + if layout not in valid_layouts: + errors.append(f"Slide {pos} : layout '{layout}' inconnu " + f"(valides : {', '.join(sorted(valid_layouts)[:5])}...)") + continue + + # Vérification des champs requis + schema = layouts[layout].get("json_schema", {}) + required_fields = schema.get("required", []) + for field in required_fields: + if field not in slide: + errors.append(f"Slide {pos} ({layout}) : champ requis '{field}' manquant") + + if errors: + return False, "\n ".join(errors), data + + return True, f"{len(slides)} slides valides.", data + + +# ───────────────────────────────────────────────────────────────────────────── +# SAUVEGARDE +# ───────────────────────────────────────────────────────────────────────────── + +def save_output(content: str, suffix: str, ext: str) -> Path: + """Sauvegarde un fichier de sortie avec timestamp.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = OUTPUT_DIR / f"sliding_{timestamp}_{suffix}.{ext}" + filename.write_text(content, encoding="utf-8") + ok(f"Sauvegardé : {filename}") + return filename + + +# ───────────────────────────────────────────────────────────────────────────── +# CHARGEMENT DES LAYOUTS (pour validation) +# ───────────────────────────────────────────────────────────────────────────── + +def load_layouts() -> dict: + if not os.path.exists(LAYOUTS_PATH): + warn(f"layouts.yaml introuvable ({LAYOUTS_PATH}) — validation désactivée") + return {} + with open(LAYOUTS_PATH, encoding="utf-8") as f: + data = yaml.safe_load(f) + return data.get("layouts", {}) + + +# ───────────────────────────────────────────────────────────────────────────── +# ÉTAPE 1 — THE NARRATOR +# ───────────────────────────────────────────────────────────────────────────── + +def run_narrator(brief: str) -> str: + """ + Lance The Narrator sur le brief. + Boucle d'approbation : valider / feedback / régénérer / afficher. + Retourne le Markdown approuvé. + """ + section("ÉTAPE 1 — THE NARRATOR") + info("Génération du Markdown narratif...") + + messages = [{"role": "user", "content": brief}] + output = call_agent(NARRATOR_ID, messages) + ok(f"Markdown généré ({len(output)} caractères)") + + while True: + print() + display_markdown(output) + + print("\n [1] Valider → passer au Designer") + print(" [2] Donner du feedback au Narrator") + print(" [3] Régénérer complètement") + print(" [4] Afficher le Markdown complet") + print(" [5] Sauvegarder le Markdown") + + choix = ask("Votre choix :") + + if choix == "1": + ok("Markdown approuvé. Passage au Designer...") + save_output(output, "narrator", "md") + return output + + elif choix == "2": + feedback = ask("Votre feedback :") + if not feedback: + continue + info("Envoi du feedback au Narrator...") + output = call_with_feedback(NARRATOR_ID, brief, feedback, output) + ok(f"Nouveau Markdown ({len(output)} caractères)") + + elif choix == "3": + info("Régénération depuis le brief...") + messages = [{"role": "user", "content": brief}] + output = call_agent(NARRATOR_ID, messages) + ok(f"Nouveau Markdown ({len(output)} caractères)") + + elif choix == "4": + print() + print(textwrap.indent(output, " ")) + + elif choix == "5": + save_output(output, "narrator_draft", "md") + + else: + warn("Choix invalide. Entrez 1 à 5.") + + +# ───────────────────────────────────────────────────────────────────────────── +# ÉTAPE 2 — THE DESIGNER +# ───────────────────────────────────────────────────────────────────────────── + +def run_designer(markdown: str) -> str: + """ + Lance The Designer sur le Markdown approuvé. + Boucle d'approbation légère : valider / feedback / afficher. + Retourne le plan texte structuré. + """ + section("ÉTAPE 2 — THE DESIGNER") + info("Génération du plan de présentation...") + + messages = [{"role": "user", "content": markdown}] + output = call_agent(DESIGNER_ID, messages) + ok(f"Plan généré ({len(output)} caractères)") + + while True: + print() + display_plan(output) + + print("\n [1] Valider → passer à l'Encoder") + print(" [2] Donner du feedback au Designer") + print(" [3] Afficher le plan complet") + print(" [4] Sauvegarder le plan") + print(" [0] Revenir au Narrator (modifier le Markdown)") + + choix = ask("Votre choix :") + + if choix == "1": + ok("Plan approuvé. Passage à l'Encoder...") + save_output(output, "designer", "txt") + return output + + elif choix == "2": + feedback = ask("Votre feedback :") + if not feedback: + continue + info("Envoi du feedback au Designer...") + output = call_with_feedback(DESIGNER_ID, markdown, feedback, output) + ok(f"Nouveau plan ({len(output)} caractères)") + + elif choix == "3": + print() + print(textwrap.indent(output, " ")) + + elif choix == "4": + save_output(output, "designer_draft", "txt") + + elif choix == "0": + return None # Signal : retour au Narrator + + else: + warn("Choix invalide. Entrez 0 à 4.") + + +# ───────────────────────────────────────────────────────────────────────────── +# ÉTAPE 3 — THE ENCODER +# ───────────────────────────────────────────────────────────────────────────── + +def run_encoder(plan: str, layouts: dict) -> tuple[str, dict]: + """ + Lance The Encoder sur le plan du Designer. + Valide le YAML produit. Boucle jusqu'à YAML valide ou abandon. + Retourne (yaml_string, yaml_dict). + """ + section("ÉTAPE 3 — THE ENCODER") + info("Encodage du plan en YAML...") + + messages = [{"role": "user", "content": plan}] + output = call_agent(ENCODER_ID, messages) + ok(f"YAML brut reçu ({len(output)} caractères)") + + attempts = 0 + max_attempts = 3 + + while attempts < max_attempts: + is_valid, message, data = validate_yaml(output, layouts) + + if is_valid: + ok(f"YAML valide : {message}") + yaml_clean = extract_yaml(output) + save_output(yaml_clean, "encoder", "yaml") + return yaml_clean, data + + else: + attempts += 1 + warn(f"YAML invalide (tentative {attempts}/{max_attempts}) :") + print(f" {message}\n") + + if attempts >= max_attempts: + warn("Nombre maximum de tentatives atteint.") + break + + info("Correction automatique...") + correction_msg = ( + f"Le YAML que tu as généré contient des erreurs :\n\n" + f"{message}\n\n" + f"Corrige ces erreurs et renvoie uniquement le YAML corrigé " + f"sans aucun texte avant ou après." + ) + output = call_with_feedback(ENCODER_ID, plan, correction_msg, output) + ok(f"YAML corrigé reçu ({len(output)} caractères)") + + # Après max_attempts : choix utilisateur + print() + print(" [1] Accepter le YAML tel quel (avec erreurs)") + print(" [2] Donner un feedback manuel à l'Encoder") + print(" [0] Abandonner et revenir au Designer") + + choix = ask("Votre choix :") + + if choix == "1": + yaml_clean = extract_yaml(output) + save_output(yaml_clean, "encoder_unvalidated", "yaml") + try: + data = yaml.safe_load(yaml_clean) or {} + except Exception: + data = {} + return yaml_clean, data + + elif choix == "2": + feedback = ask("Votre feedback :") + output = call_with_feedback(ENCODER_ID, plan, feedback, output) + yaml_clean = extract_yaml(output) + save_output(yaml_clean, "encoder_manual", "yaml") + try: + data = yaml.safe_load(yaml_clean) or {} + except Exception: + data = {} + return yaml_clean, data + + else: + return None, None # Signal : retour au Designer + + +# ───────────────────────────────────────────────────────────────────────────── +# ÉTAPE 4 — RENDER ENGINE +# ───────────────────────────────────────────────────────────────────────────── + +def run_render(yaml_data: dict) -> Optional[Path]: + """ + Appelle render_engine.py avec le YAML validé. + Retourne le chemin du PPTX généré, ou None si erreur. + """ + section("ÉTAPE 4 — RENDER ENGINE") + + # Vérifie que render_engine.py est accessible + render_path = Path("render_engine.py") + if not render_path.exists(): + warn("render_engine.py introuvable dans le dossier courant.") + warn("Vérifiez que render_engine.py est dans le même dossier que facilitator.py") + return None + + # Vérifie les YAML de config + for path in [THEME_PATH, COMPONENTS_PATH, LAYOUTS_PATH]: + if not os.path.exists(path): + warn(f"Fichier YAML manquant : {path}") + return None + + # Sauvegarde temporaire du YAML pour render_engine + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + yaml_tmp = OUTPUT_DIR / f"sliding_{timestamp}_input.yaml" + pptx_out = OUTPUT_DIR / f"sliding_{timestamp}.pptx" + + yaml_tmp.write_text( + yaml.dump(yaml_data, allow_unicode=True, default_flow_style=False), + encoding="utf-8" + ) + + info(f"Lancement de render_engine.py...") + info(f"Sortie : {pptx_out}") + + import subprocess + result = subprocess.run( + [ + sys.executable, str(render_path), + str(yaml_tmp), str(pptx_out), + "--theme", THEME_PATH, + "--components", COMPONENTS_PATH, + "--layouts", LAYOUTS_PATH, + ], + capture_output=True, + text=True, + ) + + if result.returncode == 0: + ok(result.stdout.strip() or f"PPTX généré : {pptx_out}") + return pptx_out + else: + warn("Erreur dans render_engine.py :") + print(textwrap.indent(result.stderr or result.stdout, " ")) + return None + + +# ───────────────────────────────────────────────────────────────────────────── +# BOUCLE PRINCIPALE +# ───────────────────────────────────────────────────────────────────────────── + +def run(): + banner() + + # Vérification config minimale + missing = [k for k, v in { + "MISTRAL_API_KEY": API_KEY, + "NARRATOR_AGENT_ID": NARRATOR_ID, + "DESIGNER_AGENT_ID": DESIGNER_ID, + "ENCODER_AGENT_ID": ENCODER_ID, + }.items() if not v] + + if missing: + for m in missing: + warn(f"Variable .env manquante : {m}") + sys.exit(1) + + # Chargement layouts pour validation + layouts = load_layouts() + if layouts: + ok(f"layouts.yaml chargé — {len(layouts)} layouts") + + print(" Commandes : brief libre | quit") + print(" Tapez votre brief pour démarrer une nouvelle présentation.\n") + + # ── Boucle de session ───────────────────────────────────── + while True: + try: + brief = input(" Vous : ").strip() + except (EOFError, KeyboardInterrupt): + print("\n Session terminée.") + break + + if brief.lower() in ("quit", "exit", "q"): + print(" Au revoir.") + break + + if not brief: + continue + + # ── Pipeline complet ────────────────────────────────── + + # ÉTAPE 1 — Narrator + markdown = run_narrator(brief) + if not markdown: + continue + + # ÉTAPE 2 — Designer (avec possibilité de revenir au Narrator) + while True: + plan = run_designer(markdown) + + if plan is None: + # Retour au Narrator + section("RETOUR AU NARRATOR") + info("Donnez un feedback pour modifier le Markdown :") + feedback = ask("Feedback :") + if feedback: + markdown = call_with_feedback( + NARRATOR_ID, brief, feedback, markdown) + ok(f"Markdown mis à jour ({len(markdown)} caractères)") + continue + + # ÉTAPE 3 — Encoder (avec possibilité de revenir au Designer) + yaml_str, yaml_data = run_encoder(plan, layouts) + + if yaml_str is None: + # Retour au Designer + info("Retour au Designer...") + continue + + break # YAML OK → on sort de la boucle Designer/Encoder + + # ÉTAPE 4 — Render + if yaml_data: + pptx_path = run_render(yaml_data) + + if pptx_path and pptx_path.exists(): + section("PRÉSENTATION GÉNÉRÉE") + ok(f"Fichier PPTX : {pptx_path}") + ok(f"Taille : {pptx_path.stat().st_size / 1024:.1f} Ko") + else: + warn("Le PPTX n'a pas pu être généré.") + info("Le YAML est disponible dans le dossier output/.") + + # ── Prochaine présentation ? ────────────────────────── + print() + suite = ask("Générer une nouvelle présentation ? (o/n) :").lower() + if suite not in ("o", "oui", "y", "yes"): + print(" Au revoir.") + break + + +# ───────────────────────────────────────────────────────────────────────────── +# POINT D'ENTRÉE +# ───────────────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + run() diff --git a/archive/render_engine_v1.0.py b/archive/render_engine_v1.0.py new file mode 100644 index 0000000..d2fb8df --- /dev/null +++ b/archive/render_engine_v1.0.py @@ -0,0 +1,1915 @@ +""" +render_engine.py — Sliding Design System · Pernod Ricard +========================================================= +Moteur de rendu générique JSON → PPTX. + +Usage : + from render_engine import RenderEngine + engine = RenderEngine("theme.yaml", "components.yaml", "layouts.yaml") + engine.render(json_data, "output.pptx") + +Ou en ligne de commande : + python render_engine.py presentation.json output.pptx + +Architecture : + RenderEngine.render() + └── pour chaque slide : + 1. _resolve_layout() → charge la config du layout + 2. _render_background() → fond (C01) + 3. _render_signature() → logo, barre d'accent, footer (C02-C05) + 4. _measure_title() → calcule hauteur réelle du titre + 5. _render_title() → place le titre (C02) + 6. pour chaque content_zone : + _measure_component() → hauteur réelle + _render_component() → dispatch vers le bon renderer +""" + +from __future__ import annotations + +import json +import math +import os +import sys +from pathlib import Path +from typing import Any + +import yaml +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.text import PP_ALIGN +from pptx.util import Cm, Pt, Emu +from pptx.dml.color import RGBColor +from pptx.oxml.ns import qn +from lxml import etree + + +# ───────────────────────────────────────────────────────────────────────────── +# CONSTANTES +# ───────────────────────────────────────────────────────────────────────────── + +CM = 360000 # 1 cm = 360 000 EMU +PT = 12700 # 1 pt = 12 700 EMU +SLIDE_W = 12192000 # 33.87 cm +SLIDE_H = 6858000 # 19.05 cm +FOOTER_TOP = 18.35 # cm +FOOTER_H = 0.70 # cm + + +# ───────────────────────────────────────────────────────────────────────────── +# UTILITAIRES +# ───────────────────────────────────────────────────────────────────────────── + +def cm(v: float) -> int: + """Centimètres → EMU.""" + return int(v * CM) + + +def pt(v: float) -> int: + """Points → EMU (pour line_spacing, etc.).""" + return int(v * PT) + + +def hex_to_rgb(h: str) -> RGBColor: + """'#rrggbb' → RGBColor.""" + h = h.lstrip("#") + return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) + + +def resolve_ref(value: str, theme: dict) -> str: + """ + Résout une référence theme.* dans une valeur YAML. + Ex: 'theme.colors.primary.rose' → '#ff9166' + Retourne la valeur brute si ce n'est pas une ref. + """ + if not isinstance(value, str) or not value.startswith("theme."): + return value + parts = value.split(".")[1:] # retire 'theme' + node = theme + for p in parts: + if isinstance(node, dict) and p in node: + node = node[p] + else: + return value # ref non résolue → retourne telle quelle + return node + + +def add_text_box(slide, left, top, width, height, + text, font_name, font_size_pt, bold=False, italic=False, + color="#000000", align=PP_ALIGN.LEFT, word_wrap=True): + """Ajoute une text box sur le slide. Retourne le shape.""" + txBox = slide.shapes.add_textbox(cm(left), cm(top), cm(width), cm(height)) + tf = txBox.text_frame + tf.word_wrap = word_wrap + p = tf.paragraphs[0] + p.alignment = align + run = p.add_run() + run.text = text + run.font.name = font_name + run.font.size = Pt(font_size_pt) + run.font.bold = bold + run.font.italic = italic + run.font.color.rgb = hex_to_rgb(color) + return txBox + + +def add_rect(slide, left, top, width, height, fill_color, border_color=None, border_width_cm=0): + """Ajoute un rectangle plein. Retourne le shape.""" + shape = slide.shapes.add_shape( + 1, # MSO_SHAPE_TYPE.RECTANGLE + cm(left), cm(top), cm(width), cm(height) + ) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(fill_color) + if border_color and border_width_cm > 0: + shape.line.color.rgb = hex_to_rgb(border_color) + shape.line.width = cm(border_width_cm) + else: + shape.line.fill.background() + return shape + + +def add_line(slide, x1, y1, x2, y2, color="#e8e2d6", width_cm=0.03): + """Ajoute une ligne.""" + from pptx.util import Emu + connector = slide.shapes.add_connector(1, cm(x1), cm(y1), cm(x2), cm(y2)) + connector.line.color.rgb = hex_to_rgb(color) + connector.line.width = cm(width_cm) + return connector + + +def estimate_text_height(text: str, font_size_pt: float, + box_width_cm: float, line_spacing: float = 1.15) -> float: + """ + Estime la hauteur en cm d'un texte dans une boîte. + Heuristique : ~2.2 caractères par cm de largeur à 11pt, scaled par font_size. + """ + chars_per_line = max(1, int(box_width_cm * 2.2 * (11 / font_size_pt))) + lines = 0 + for paragraph in text.split("\n"): + if not paragraph.strip(): + lines += 0.5 + continue + lines += math.ceil(len(paragraph) / chars_per_line) + line_height_cm = font_size_pt * 0.035 * line_spacing + return lines * line_height_cm + + +# ───────────────────────────────────────────────────────────────────────────── +# RENDER ENGINE +# ───────────────────────────────────────────────────────────────────────────── + +class RenderEngine: + """ + Moteur principal. Charge les 3 YAML, expose render(json_data, output_path). + """ + + def __init__(self, theme_path: str, components_path: str, layouts_path: str): + with open(theme_path, encoding="utf-8") as f: + self.theme = yaml.safe_load(f) + with open(components_path, encoding="utf-8") as f: + self.components = yaml.safe_load(f)["components"] + with open(layouts_path, encoding="utf-8") as f: + data = yaml.safe_load(f) + self.layouts = data["layouts"] + + # Polices résolues (avec fallback si non installées) + self._font_display = self._resolve_font("display") + self._font_body = self._resolve_font("body") + + # Cycle couleur (index global, remis à zéro par présentation) + self._cycle_index = 0 + + # ── Résolution des polices ───────────────────────────────────────────── + + def _resolve_font(self, role: str) -> str: + """Retourne le nom de police à utiliser (tentative + fallback).""" + font_cfg = self.theme["typography"][role] + primary = font_cfg["family"] + fallback = font_cfg["fallback"] + # On tente d'utiliser la police primaire. python-pptx l'intègre par + # nom — si elle n'est pas installée sur la machine cible, PowerPoint + # utilisera la police système la plus proche. + return primary # fallback géré à l'ouverture du PPTX côté utilisateur + + def _font(self, role: str) -> str: + return self._font_display if role == "display" else self._font_body + + # ── Résolution des refs theme ────────────────────────────────────────── + + def _r(self, value: Any) -> Any: + """Résout une ref theme.* si nécessaire.""" + return resolve_ref(value, self.theme) + + def _cycle_color(self) -> str: + colors = self.theme["colors"]["cycle"] + c = colors[self._cycle_index % len(colors)] + self._cycle_index += 1 + return c + + # ── Mesure ──────────────────────────────────────────────────────────── + + def _measure_title(self, layout_cfg: dict, slide_data: dict) -> float: + """ + Calcule la hauteur réelle occupée par le bloc titre + sous-titre. + Retourne la hauteur en cm. + """ + tz = layout_cfg.get("title_zone") + if not tz: + return 0.0 + + titre = slide_data.get("titre", "") + sous_titre = slide_data.get("sous_titre", "") + + font_override = tz.get("font_override", {}) + size_pt = font_override.get("size_pt", + self.theme["typography"]["sizes"]["slide_title"]) + width_cm = tz.get("width_cm", 30.0) + + h = estimate_text_height(titre, size_pt, width_cm, 1.1) + if sous_titre: + sub_size = tz.get("subtitle", {}).get("size_pt", + self.theme["typography"]["sizes"]["slide_subtitle"]) + h += estimate_text_height(sous_titre, sub_size, width_cm, 1.1) + h += 0.15 # marge entre titre et sous-titre + return max(h, 0.60) # minimum 0.6 cm + + def _measure_component(self, zone: dict, slide_data: dict) -> float: + """ + Estime la hauteur réelle d'une content_zone selon son contenu. + Retourne la hauteur en cm. Si non estimable, retourne height_cm du layout. + """ + comp_id = zone.get("component", "") + max_h = zone.get("height_cm", 14.0) + + # bullet_list + if comp_id == "C06": + bullets = slide_data.get("bullets", []) + if not bullets: + # cherche dans les sous-clés (two_cols, etc.) + return max_h + total_h = 0.0 + for b in bullets: + lvl = b.get("niveau", 1) + size_pt = [11, 10, 9][min(lvl - 1, 2)] + w = zone.get("width_cm", 28.0) + total_h += estimate_text_height( + b.get("texte", ""), size_pt, w - (lvl - 1) * 0.5) + total_h += [0.14, 0.08, 0.04][min(lvl - 1, 2)] + return min(total_h + 0.3, max_h) + + # text_paragraph (executive_summary blocs) + if comp_id == "C07": + # cherche le champ associé dans slide_data + for key in ["situation", "complication", "resolution", + "contenu", "description"]: + if key in slide_data: + txt = slide_data[key] + h = estimate_text_height(txt, 11, zone.get("width_cm", 28.0)) + return min(h + 0.6, max_h) # +0.6 pour le titre de bloc + return max_h + + # kpi_grid → hauteur calculée selon nb items + if comp_id == "C09": + items = slide_data.get("items", []) + n = len(items) + grid = {2: (2, 1), 3: (3, 1), 4: (2, 2), 5: (3, 2), 6: (3, 2)} + cols, rows = grid.get(n, (3, 2)) + card_h = 4.5 # hauteur d'une carte KPI en cm + gap = 0.4 + return min(rows * card_h + (rows - 1) * gap, max_h) + + # big_stat → hauteur fixe + if comp_id == "C10": + return max_h + + # Pour tous les autres composants visuels complexes → hauteur max + return max_h + + # ── Rendu principal ─────────────────────────────────────────────────── + + def render(self, json_data: dict | str, output_path: str): + """ + Point d'entrée. Accepte un dict ou une chaîne JSON. + Produit le fichier PPTX à output_path. + """ + if isinstance(json_data, str): + json_data = json.loads(json_data) + + prs = Presentation() + prs.slide_width = Emu(SLIDE_W) + prs.slide_height = Emu(SLIDE_H) + + # Supprime les layouts par défaut (on dessine tout manuellement) + blank_layout = prs.slide_layouts[6] # layout "blank" + + self._cycle_index = 0 + slides = json_data.get("slides", []) + + for i, slide_data in enumerate(slides): + slide = prs.slides.add_slide(blank_layout) + layout_name = slide_data.get("layout", "default_bullets") + self._render_slide(slide, slide_data, layout_name, i + 1, len(slides)) + + prs.save(output_path) + print(f"✓ PPTX généré : {output_path} ({len(slides)} slides)") + + def _render_slide(self, slide, slide_data: dict, layout_name: str, + slide_num: int, total: int): + """Orchestre le rendu d'un slide complet.""" + layout_cfg = self.layouts.get(layout_name) + if not layout_cfg: + print(f" ⚠ Layout inconnu '{layout_name}' → fallback default_bullets") + layout_cfg = self.layouts["default_bullets"] + layout_name = "default_bullets" + + # ── 1. Background ───────────────────────────────────────────────── + self._render_background(slide, layout_cfg) + + # ── 2. Signature (footer, logo, accent bar) ─────────────────────── + self._render_footer(slide, layout_name, slide_num) + self._render_logo(slide, layout_name) + + # ── 3. Titre + mesure ───────────────────────────────────────────── + title_h = self._measure_title(layout_cfg, slide_data) + title_bottom = self._render_title(slide, layout_cfg, slide_data, title_h) + self._render_accent_bar(slide, layout_name, title_h) + + # ── 4. Content zones ────────────────────────────────────────────── + zones = layout_cfg.get("content_zones") or [] + # cursor : commence juste sous le titre + cursor_y = title_bottom + 0.20 if title_bottom else 2.80 + # zone max disponible (jusqu'au footer ou bas du slide) + max_bottom = FOOTER_TOP - 0.30 # laisse 0.3 cm au-dessus du footer + + for zone in zones: + # Zones optionnelles absentes du JSON → skip + if zone.get("optional") and not self._zone_has_data(zone, slide_data): + continue + + # Positions : priorité aux coords fixes, sinon on utilise le curseur + z_left = zone.get("left_cm", 1.50) + z_top = zone.get("top_cm", cursor_y) + z_width = zone.get("width_cm", 30.87) + + # Calcul de la hauteur réelle + measured_h = self._measure_component(zone, slide_data) + z_height = min(measured_h, max_bottom - z_top) + if z_height <= 0: + continue # plus de place + + # Mise à jour du curseur (uniquement pour les zones sans top fixe) + if "top_cm" not in zone: + cursor_y = z_top + z_height + 0.25 + + self._render_zone(slide, zone, slide_data, + z_left, z_top, z_width, z_height) + + # ── Background ──────────────────────────────────────────────────────── + + def _render_background(self, slide, layout_cfg: dict): + """Rend le fond du slide (C01).""" + bg = layout_cfg.get("background", {}) + color = self._r(bg.get("color", "#ffffff")) + + if bg.get("diagonal_split"): + color_right = self._r(bg.get("color_right", "#023466")) + angle = bg.get("diagonal_angle_deg", 15) + self._render_diagonal_background(slide, color, color_right, angle) + else: + add_rect(slide, 0, 0, 33.87, 19.05, color) + + def _render_diagonal_background(self, slide, color_left: str, + color_right: str, angle_deg: float): + """Fond splitté diagonal : rectangle gauche + triangle droit.""" + # Panneau gauche plein + add_rect(slide, 0, 0, 33.87, 19.05, color_left) + # Panneau droit via freeform (triangle) + # La diagonale va du point (split_x, 0) au point (split_x - offset, 19.05) + split_x = 20.0 # cm — point haut de la diagonale + offset = 19.05 * math.tan(math.radians(angle_deg)) + split_x_bottom = split_x - offset + + from pptx.util import Emu + from pptx.oxml.ns import qn + + # Utilise add_shape freeform via XML pour le triangle + sp = slide.shapes.add_shape(1, + cm(split_x_bottom), cm(0), + cm(33.87 - split_x_bottom), cm(19.05)) + sp.fill.solid() + sp.fill.fore_color.rgb = hex_to_rgb(color_right) + sp.line.fill.background() + + # Note : python-pptx ne supporte pas les freeforms nativement. + # Pour un vrai triangle, il faudrait manipuler l'XML OOXML directement. + # Cette version utilise un rectangle approché — suffisant pour l'aperçu. + # TODO : implémenter la forme triangulaire via lxml si rendu exact requis. + + # ── Signature ───────────────────────────────────────────────────────── + + def _render_footer(self, slide, layout_name: str, slide_num: int): + """Rend le footer PR (C04).""" + footer_cfg = self.theme["signature"]["footer"] + hidden_on = footer_cfg.get("hidden_on", []) + if layout_name in hidden_on: + return + + top = FOOTER_TOP + h = FOOTER_H + w = 33.87 + + # Fond blanc + add_rect(slide, 0, top, w, h, "#ffffff") + # Bordure top + add_line(slide, 0, top, w, top, "#e8e2d6", 0.03) + + # Numéro de slide + add_text_box(slide, 0.80, top + 0.10, 1.50, 0.50, + str(slide_num), self._font_body, 8, + color="#000a32", align=PP_ALIGN.LEFT) + + # Séparateur vertical + add_line(slide, 1.50, top + 0.10, 1.50, top + 0.60, "#7fa5d0", 0.03) + + # "Pernod Ricard" + add_text_box(slide, 1.70, top + 0.10, 5.00, 0.50, + "Pernod Ricard", self._font_body, 8, + color="#000a32", align=PP_ALIGN.LEFT) + + # Tagline à droite + add_text_box(slide, 15.00, top + 0.10, 18.00, 0.50, + "DATA GOVERNANCE DATA MANAGEMENT", + self._font_body, 7, + color="#48545a", align=PP_ALIGN.RIGHT) + + def _render_logo(self, slide, layout_name: str): + """Insère le logo PR top-left si le fichier assets/logo_pr_sun.png existe.""" + logo_cfg = self.theme["signature"]["logo_topbar"] + visible_on = logo_cfg.get("visible_on", []) + if layout_name not in visible_on: + return + + logo_path = logo_cfg.get("file", "assets/logo_pr_sun.png") + if not os.path.exists(logo_path): + # Logo absent → on dessine un proxy (cercle orange petit) + shape = slide.shapes.add_shape(9, # ellipse + cm(logo_cfg["position_left_cm"]), + cm(logo_cfg["position_top_cm"]), + cm(logo_cfg["height_cm"]), + cm(logo_cfg["height_cm"])) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb("#ff9166") + shape.line.fill.background() + return + + slide.shapes.add_picture( + logo_path, + cm(logo_cfg["position_left_cm"]), + cm(logo_cfg["position_top_cm"]), + height=cm(logo_cfg["height_cm"]) + ) + + def _render_accent_bar(self, slide, layout_name: str, title_h: float): + """Barre verticale rose à gauche du titre (C03).""" + sig = self.theme["signature"]["accent_bar"] + if layout_name not in sig.get("visible_on", []): + return + + bar_h = max(title_h, 0.60) + add_rect(slide, + sig["position_left_cm"], 0.45, + sig["width_cm"], bar_h, + sig["color"]) + + # ── Titre ───────────────────────────────────────────────────────────── + + def _render_title(self, slide, layout_cfg: dict, + slide_data: dict, title_h: float) -> float: + """Rend le titre et le sous-titre. Retourne le y_bottom en cm.""" + tz = layout_cfg.get("title_zone") + if not tz: + return 0.0 + + titre = slide_data.get("titre", "") + sous_titre = slide_data.get("sous_titre", "") + + left = tz.get("left_cm", 1.80) + top = tz.get("top_cm", 0.45) + width = tz.get("width_cm", 30.00) + + font_override = tz.get("font_override", {}) + size_pt = font_override.get("size_pt", + self.theme["typography"]["sizes"]["slide_title"]) + bold = font_override.get("bold", True) + color = self._r(font_override.get("color", + self.theme["colors"]["text"]["on_white"])) + + # Titre principal + h_titre = estimate_text_height(titre, size_pt, width, 1.1) + h_titre = max(h_titre, size_pt * 0.035 + 0.1) + add_text_box(slide, left, top, width, h_titre + 0.20, + titre, self._font_display, size_pt, + bold=bold, color=color) + + current_bottom = top + h_titre + 0.20 + + # Sous-titre + if sous_titre: + sub_cfg = tz.get("subtitle", {}) + sub_size = sub_cfg.get("size_pt", + self.theme["typography"]["sizes"]["slide_subtitle"]) + sub_color = self._r(sub_cfg.get("color", + self.theme["colors"]["text"]["subtitle"])) + margin = sub_cfg.get("margin_top_cm", 0.10) + add_text_box(slide, left, current_bottom + margin, + width, 0.60, + sous_titre, self._font_body, sub_size, + color=sub_color) + current_bottom += margin + 0.60 + + return current_bottom + + # ── Dispatch des zones ──────────────────────────────────────────────── + + def _zone_has_data(self, zone: dict, slide_data: dict) -> bool: + """Vérifie si une zone optionnelle a des données dans le JSON.""" + comp = zone.get("component", "") + if comp == "C07": + return any(k in slide_data for k in + ["description", "situation", "complication", "resolution", "contenu"]) + return True + + def _render_zone(self, slide, zone: dict, slide_data: dict, + left: float, top: float, width: float, height: float): + """Dispatche vers le renderer du composant.""" + comp = zone.get("component", "") + zone_type = zone.get("type", "") + + # Séparateurs (pas de composant associé) + if zone_type == "vertical_line": + add_line(slide, zone.get("x_cm", left), + zone.get("top_cm", top), + zone.get("x_cm", left), + zone.get("top_cm", top) + zone.get("height_cm", height), + self._r(zone.get("color", "#e8e2d6")), + zone.get("width_cm", 0.03)) + return + if zone_type == "horizontal_line": + y = zone.get("y_cm", top) + add_line(slide, left, y, left + width, y, + self._r(zone.get("color", "#e8e2d6")), + zone.get("width_cm", 0.03)) + return + + dispatch = { + "C06": self._render_bullet_list, + "C07": self._render_text_paragraph, + "C08": self._render_quote_block, + "C09": self._render_kpi_grid, + "C10": self._render_big_stat, + "C11": self._render_data_table, + "C12": self._render_chart_placeholder, + "C13": self._render_callout_box, + "C14": self._render_benchmark, + "C15": self._render_matrix_2x2, + "C16": self._render_pyramid, + "C17": self._render_circular_diagram, + "C18": self._render_from_to_pairs, + "C19": self._render_numbered_steps, + "C20": self._render_chevrons, + "C21": self._render_gantt, + "C22": self._render_timeline, + "C23": self._render_org_chart, + "C24": self._render_raci, + "C25": self._render_decision_tree, + "C26": self._render_recommendation_sidebar, + } + + renderer = dispatch.get(comp) + if renderer: + renderer(slide, zone, slide_data, left, top, width, height) + else: + # Composant inconnu → zone grise placeholder + self._render_placeholder(slide, left, top, width, height, comp) + + # ── Renderers des composants ────────────────────────────────────────── + + def _render_placeholder(self, slide, left, top, width, height, label="?"): + """Zone placeholder pour composants non encore implémentés.""" + add_rect(slide, left, top, width, height, "#f5f1ea") + add_text_box(slide, left + 0.5, top + height / 2 - 0.3, + width - 1, 0.6, + f"[ {label} — à implémenter ]", + self._font_body, 10, color="#9a9a9a", + align=PP_ALIGN.CENTER) + + # C06 — bullet_list ──────────────────────────────────────────────────── + + def _render_bullet_list(self, slide, zone, slide_data, + left, top, width, height): + """Bullets hiérarchisés L1/L2/L3.""" + # Cherche les bullets dans le JSON (champ direct ou dans une colonne) + zone_id = zone.get("id", "") + if "col_left" in zone_id: + col_data = slide_data.get("left", {}) + elif "col_right" in zone_id: + col_data = slide_data.get("right", {}) + else: + col_data = slide_data + + bullets = col_data.get("bullets", []) + if not bullets: + return + + txBox = slide.shapes.add_textbox( + cm(left), cm(top), cm(width), cm(height)) + tf = txBox.text_frame + tf.word_wrap = True + + sizes = {1: 11, 2: 10, 3: 9} + colors = { + 1: "#000a32", + 2: self.theme["colors"]["text"]["body"], + 3: self.theme["colors"]["text"]["body"], + } + indents = {1: 0, 2: 0.5, 3: 1.0} + markers = {1: "• ", 2: "– ", 3: "▪ "} + space_before = {1: Pt(4), 2: Pt(2), 3: Pt(1)} + + first = True + for b in bullets: + lvl = b.get("niveau", 1) + text = b.get("texte", "") + + p = tf.paragraphs[0] if first else tf.add_paragraph() + first = False + p.space_before = space_before.get(lvl, Pt(4)) + p.alignment = PP_ALIGN.LEFT + + # Indentation via l'XML (level) + pPr = p._p.get_or_add_pPr() + pPr.set("lvl", str(lvl - 1)) + + run = p.add_run() + run.text = markers[lvl] + text + run.font.name = self._font_body + run.font.size = Pt(sizes[lvl]) + run.font.bold = (lvl == 1) + run.font.color.rgb = hex_to_rgb(colors[lvl]) + + # Sous-items récursifs + for sub in b.get("sous_items", []) or []: + p2 = tf.add_paragraph() + p2.alignment = PP_ALIGN.LEFT + run2 = p2.add_run() + run2.text = " – " + sub + run2.font.name = self._font_body + run2.font.size = Pt(9) + run2.font.color.rgb = hex_to_rgb(self.theme["colors"]["text"]["body"]) + + # C07 — text_paragraph ───────────────────────────────────────────────── + + def _render_text_paragraph(self, slide, zone, slide_data, + left, top, width, height): + """Bloc de texte libre avec titre de bloc optionnel.""" + zone_id = zone.get("id", "") + + # Mapping zone_id → champ JSON + field_map = { + "bloc_situation": ("Situation", "situation"), + "bloc_complication": ("Complication", "complication"), + "bloc_resolution": ("Résolution", "resolution"), + "col_left": (None, "left"), + "col_right": (None, "right"), + "description_bloc": (None, "description"), + "contact": (None, "contacts"), + "next_steps": (None, "message"), + } + + titre_bloc, field = field_map.get(zone_id, (None, "contenu")) + titre_couleur = self._r(zone.get("titre_couleur", + self.theme["colors"]["primary"]["dark_blue"])) + font_override = zone.get("font_override", {}) + + cur_top = top + + # Titre de bloc + if titre_bloc: + add_text_box(slide, left, cur_top, width, 0.50, + titre_bloc, self._font_body, 13, + bold=True, color=titre_couleur) + cur_top += 0.55 + + # Contenu + raw = slide_data.get(field, "") + if isinstance(raw, dict): + titre_col = raw.get("titre", "") + contenu = raw.get("contenu", "") + if titre_col: + add_text_box(slide, left, cur_top, width, 0.45, + titre_col, self._font_body, 13, + bold=True, color=titre_couleur) + cur_top += 0.50 + raw = contenu + + if raw: + color = font_override.get("color", self.theme["colors"]["text"]["body"]) + size_pt = font_override.get("size_pt", 11) + add_text_box(slide, left, cur_top, width, + height - (cur_top - top), + str(raw), self._font_body, size_pt, + color=color) + + # C08 — quote_block ──────────────────────────────────────────────────── + + def _render_quote_block(self, slide, zone, slide_data, + left, top, width, height): + """Citation / key message avec guillemets Cormorant.""" + citation = slide_data.get("message") or slide_data.get("citation", "") + auteur = slide_data.get("auteur", "") + fonction = slide_data.get("fonction", "") + + # Guillemet décoratif + add_text_box(slide, left, top + 0.3, 2.0, 1.5, + "\u201C", self._font_display, 72, + color=self.theme["colors"]["primary"]["bright_blue"]) + + # Message + add_text_box(slide, left + 1.5, top + 1.2, + width - 1.5, height - 2.0, + citation, self._font_display, 22, + color=self.theme["colors"]["primary"]["dark_blue"]) + + # Attribution + if auteur or fonction: + attr = f"{auteur} {fonction}".strip() + add_text_box(slide, left + 1.5, + top + height - 1.2, + width - 1.5, 0.60, + attr, self._font_body, 10, + color=self.theme["colors"]["text"]["body"]) + + # C09 — kpi_grid ─────────────────────────────────────────────────────── + + def _render_kpi_grid(self, slide, zone, slide_data, + left, top, width, height): + """Grille de cartes KPI adaptative.""" + items = slide_data.get("items", []) + if not items: + return + + n = len(items) + grid = {2: (2, 1), 3: (3, 1), 4: (2, 2), 5: (3, 2), 6: (3, 2)} + cols, rows = grid.get(n, (3, 2)) + gap = 0.40 + + card_w = (width - (cols - 1) * gap) / cols + card_h = (height - (rows - 1) * gap) / rows + + for i, item in enumerate(items): + col = i % cols + row = i // cols + x = left + col * (card_w + gap) + y = top + row * (card_h + gap) + + color = item.get("couleur") or self._cycle_color() + color = self._r(color) + header_h = 0.55 + + # Header coloré + add_rect(slide, x, y, card_w, header_h, color) + add_text_box(slide, x + 0.2, y + 0.10, + card_w - 0.4, header_h - 0.10, + item.get("titre", ""), + self._font_body, 9, + bold=True, color="#ffffff") + + # Body beige + add_rect(slide, x, y + header_h, card_w, + card_h - header_h, + self.theme["colors"]["backgrounds"]["content_area"]) + + # Valeur en gros + val_h = card_h - header_h - 1.0 + add_text_box(slide, x + 0.2, y + header_h + 0.3, + card_w - 0.4, val_h, + item.get("valeur", ""), + self._font_display, 32, + bold=True, + color=self.theme["colors"]["primary"]["rose"], + align=PP_ALIGN.CENTER) + + # Sous-titre + if item.get("sous_titre"): + add_text_box(slide, x + 0.2, + y + card_h - 0.8, + card_w - 0.4, 0.70, + item["sous_titre"], + self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # C10 — big_stat_display ─────────────────────────────────────────────── + + def _render_big_stat(self, slide, zone, slide_data, + left, top, width, height): + """Chiffre unique centré en très grand format.""" + valeur = slide_data.get("valeur", "") + label = slide_data.get("label", "") + source = slide_data.get("source", "") + + center_top = top + (height - 4.0) / 2 + + # Valeur + add_text_box(slide, left, center_top, width, 2.80, + valeur, self._font_display, 72, + bold=True, + color=self.theme["colors"]["primary"]["rose"], + align=PP_ALIGN.CENTER) + + if label: + add_text_box(slide, left, center_top + 2.90, width, 0.70, + label, self._font_body, 11, + color=self.theme["colors"]["text"]["body"], + align=PP_ALIGN.CENTER) + if source: + add_text_box(slide, left, center_top + 3.70, width, 0.50, + f"Source : {source}", self._font_body, 9, + color=self.theme["colors"]["text"]["caption"], + align=PP_ALIGN.CENTER) + + # C11 — data_table ───────────────────────────────────────────────────── + + def _render_data_table(self, slide, zone, slide_data, + left, top, width, height): + """Tableau structuré avec header bleu foncé et lignes alternées.""" + headers = slide_data.get("headers", []) + rows = slide_data.get("rows", []) + if not headers: + return + + highlight_col = slide_data.get("highlight_col") + col_widths_pct = slide_data.get("col_widths") + + n_cols = len(headers) + header_h = 0.65 + available_h = height - header_h + row_h = min(available_h / max(len(rows), 1), 0.80) + + # Largeurs de colonnes + if col_widths_pct: + col_widths = [w * width for w in col_widths_pct] + else: + col_widths = [width / n_cols] * n_cols + + # Header + x = left + for j, h in enumerate(headers): + add_rect(slide, x, top, col_widths[j], header_h, + self.theme["colors"]["primary"]["dark_blue"]) + add_text_box(slide, x + 0.15, top + 0.10, + col_widths[j] - 0.3, header_h - 0.15, + str(h), self._font_body, 9, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + x += col_widths[j] + + # Lignes + odd_bg = "#ffffff" + even_bg = self.theme["colors"]["backgrounds"]["content_area"] + highlight_bg = self.theme["colors"]["backgrounds"]["highlight_box"] + + for i, row in enumerate(rows): + y = top + header_h + i * row_h + x = left + for j, cell in enumerate(row): + bg = highlight_bg if j == highlight_col else ( + odd_bg if i % 2 == 0 else even_bg) + add_rect(slide, x, y, col_widths[j], row_h, bg) + add_text_box(slide, x + 0.15, y + 0.08, + col_widths[j] - 0.3, row_h - 0.10, + str(cell), self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + x += col_widths[j] + # Ligne séparatrice + add_line(slide, left, y + row_h, left + width, y + row_h, + "#e8e2d6", 0.02) + + # C12 — chart_placeholder ────────────────────────────────────────────── + + def _render_chart_placeholder(self, slide, zone, slide_data, + left, top, width, height): + """ + Graphique simplifié (bar chart) généré avec python-pptx Chart. + Pour un rendu avancé, remplacer par openpyxl + pptx chart data. + """ + from pptx.chart.data import ChartData + from pptx.enum.chart import XL_CHART_TYPE + + data_items = slide_data.get("data", []) + chart_type = slide_data.get("chart_type", "bar") + if not data_items: + self._render_placeholder(slide, left, top, width, height, "C12 chart") + return + + chart_data = ChartData() + chart_data.categories = [str(d.get("label", f"Item {i+1}")) + for i, d in enumerate(data_items)] + chart_data.add_series("", [float(d.get("valeur", 0)) + for d in data_items]) + + xl_type = { + "bar": XL_CHART_TYPE.BAR_CLUSTERED, + "line": XL_CHART_TYPE.LINE, + "pie": XL_CHART_TYPE.PIE, + "donut": XL_CHART_TYPE.DOUGHNUT, + }.get(chart_type, XL_CHART_TYPE.BAR_CLUSTERED) + + chart = slide.shapes.add_chart( + xl_type, + cm(left), cm(top), cm(width), cm(height), + chart_data + ).chart + + # Supprimer le titre du chart (on a déjà le titre du slide) + chart.has_title = False + chart.has_legend = False + + # Couleur des barres + series = chart.series[0] + fill = series.format.fill + fill.solid() + fill.fore_color.rgb = hex_to_rgb( + self.theme["colors"]["primary"]["bright_blue"]) + + # C13 — callout_box ──────────────────────────────────────────────────── + + def _render_callout_box(self, slide, zone, slide_data, + left, top, width, height): + """Encadré d'insight jaune.""" + insight = slide_data.get("insight", "") + titre = slide_data.get("titre_insight", "") + + # Fond + add_rect(slide, left, top, width, height, + self.theme["colors"]["backgrounds"]["highlight_box"], + self.theme["colors"]["secondary"]["maize_yellow"], 0.05) + + cur_top = top + 0.30 + if titre: + add_text_box(slide, left + 0.30, cur_top, + width - 0.60, 0.50, + titre, self._font_body, 12, + bold=True, + color=self.theme["colors"]["primary"]["rose"]) + cur_top += 0.55 + + if insight: + add_text_box(slide, left + 0.30, cur_top, + width - 0.60, height - (cur_top - top) - 0.30, + insight, self._font_body, 10, + color=self.theme["colors"]["text"]["body"]) + + # C14 — benchmark_bar ────────────────────────────────────────────────── + + def _render_benchmark(self, slide, zone, slide_data, + left, top, width, height): + """Barres horizontales de benchmark.""" + criteria = slide_data.get("criteria", []) + actors = slide_data.get("actors", []) + scores = slide_data.get("scores", []) + if not criteria or not actors: + return + + colors_actors = slide_data.get("couleurs_acteurs") or [ + self.theme["colors"]["primary"]["bright_blue"], + self.theme["colors"]["primary"]["rose"], + self.theme["colors"]["secondary"]["barley_green"], + self.theme["colors"]["secondary"]["maize_yellow"], + ] + + n_crit = len(criteria) + n_act = len(actors) + label_w = 5.00 + bar_area_w = width - label_w + row_h = height / n_crit + bar_h = 0.30 + bar_gap = 0.10 + + # Légende acteurs (en haut) + for j, actor in enumerate(actors): + add_rect(slide, left + label_w + j * 2.0, top - 0.50, + 0.25, 0.25, colors_actors[j % len(colors_actors)]) + add_text_box(slide, left + label_w + j * 2.0 + 0.30, + top - 0.55, 1.5, 0.35, + actor, self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + for i, crit in enumerate(criteria): + y = top + i * row_h + + # Label critère + add_text_box(slide, left, y + row_h / 2 - 0.20, + label_w - 0.30, 0.40, + crit, self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # Barres par acteur + for j in range(n_act): + score = 0 + if i < len(scores) and j < len(scores[i]): + score = float(scores[i][j]) + bar_w = (score / 100) * bar_area_w + + bar_y = y + (row_h - n_act * (bar_h + bar_gap)) / 2 + j * (bar_h + bar_gap) + add_rect(slide, left + label_w, bar_y, + max(bar_w, 0.05), bar_h, + colors_actors[j % len(colors_actors)]) + + # C15 — matrix_bubble ───────────────────────────────────────────────── + + def _render_matrix_2x2(self, slide, zone, slide_data, + left, top, width, height): + """Matrice 2×2 avec bulles positionnées.""" + axis_x = slide_data.get("axis_x", {}) + axis_y = slide_data.get("axis_y", {}) + items = slide_data.get("items", []) + + ax_label = str(axis_x.get("label", "")) + ay_label = str(axis_y.get("label", "")) + + # Marges pour les labels d'axes + margin_left = 1.50 + margin_bottom = 0.80 + plot_w = width - margin_left + plot_h = height - margin_bottom + + # Axes + add_line(slide, left + margin_left, top, + left + margin_left, top + plot_h, + "#000a32", 0.05) + add_line(slide, left + margin_left, top + plot_h, + left + width, top + plot_h, + "#000a32", 0.05) + + # Lignes de quadrant + mid_x = left + margin_left + plot_w / 2 + mid_y = top + plot_h / 2 + add_line(slide, mid_x, top, mid_x, top + plot_h, "#48545a", 0.02) + add_line(slide, left + margin_left, mid_y, + left + width, mid_y, "#48545a", 0.02) + + # Labels axes + add_text_box(slide, left + margin_left + plot_w / 2 - 2, + top + plot_h + 0.10, + 4, 0.40, ax_label, + self._font_body, 9, color="#48545a", + align=PP_ALIGN.CENTER) + add_text_box(slide, left, top + plot_h / 2 - 0.30, + margin_left - 0.10, 0.60, ay_label, + self._font_body, 9, color="#48545a", + align=PP_ALIGN.RIGHT) + + # Labels extremes + add_text_box(slide, left + margin_left - 0.5, top + plot_h - 0.20, + 0.8, 0.30, "Low", self._font_body, 8, color="#48545a") + add_text_box(slide, left + margin_left - 0.5, top, + 0.8, 0.30, "High", self._font_body, 8, color="#48545a") + add_text_box(slide, left + margin_left, top + plot_h, + 0.8, 0.30, "Low", self._font_body, 8, color="#48545a") + add_text_box(slide, left + width - 1.0, top + plot_h, + 1.0, 0.30, "High", self._font_body, 8, color="#48545a", + align=PP_ALIGN.RIGHT) + + colors = self.theme["colors"]["cycle"] + for i, item in enumerate(items): + x_pct = item.get("x", 50) / 100 + y_pct = 1 - item.get("y", 50) / 100 # inverser y (0 = bas) + size_factor = item.get("taille", 2) + diameter = 0.30 + (size_factor - 1) * 0.15 + color = self._r(item.get("couleur") or colors[i % len(colors)]) + + bx = left + margin_left + x_pct * plot_w - diameter / 2 + by = top + y_pct * plot_h - diameter / 2 + + shape = slide.shapes.add_shape(9, # ellipse + cm(bx), cm(by), cm(diameter), cm(diameter)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(color) + shape.fill.fore_color.theme_color + shape.line.fill.background() + # Opacité via XML + spPr = shape._element.spPr + solidFill = spPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}solidFill') + if solidFill is not None: + srgbClr = solidFill.find('{http://schemas.openxmlformats.org/drawingml/2006/main}srgbClr') + if srgbClr is not None: + alpha = etree.SubElement(srgbClr, + '{http://schemas.openxmlformats.org/drawingml/2006/main}alpha') + alpha.set('val', '75000') # 75% opacité + + # Label + add_text_box(slide, bx - 0.5, by + diameter + 0.05, + diameter + 1.0, 0.35, + item.get("label", ""), + self._font_body, 8, + color=self.theme["colors"]["primary"]["dark_blue"], + align=PP_ALIGN.CENTER) + + # C16 — pyramid_level ───────────────────────────────────────────────── + + def _render_pyramid(self, slide, zone, slide_data, + left, top, width, height): + """Pyramide hiérarchique.""" + levels = slide_data.get("levels", []) + if not levels: + return + + n = len(levels) + colors_default = [ + "#7fa5d0", "#000a32", "#bad9ff", "#ffcf0f", "#d9d9c4" + ] + level_h = height / n + center_x = left + width / 2 + max_w = width * 0.55 + callout_right_x = left + width * 0.70 + + for i, level in enumerate(levels): + rank = i + 1 + frac = rank / n + lvl_w = max_w * frac + lvl_left = center_x - lvl_w / 2 + lvl_top = top + i * level_h + color = self._r(level.get("couleur") or colors_default[i % len(colors_default)]) + + add_rect(slide, lvl_left, lvl_top, lvl_w, level_h - 0.05, color) + add_text_box(slide, lvl_left, lvl_top + level_h / 2 - 0.20, + lvl_w, 0.40, + level.get("label", ""), + self._font_body, 9, + bold=True, + color="#ffffff" if i in [1] else "#000a32", + align=PP_ALIGN.CENTER) + + # Callout latéral + if level.get("description"): + side = "right" if i % 2 == 0 else "left" + if side == "right": + add_line(slide, lvl_left + lvl_w, lvl_top + level_h / 2, + callout_right_x, lvl_top + level_h / 2, + "#48545a", 0.02) + add_text_box(slide, callout_right_x + 0.10, + lvl_top + level_h / 2 - 0.20, + left + width - callout_right_x - 0.20, + 0.60, level["description"], + self._font_body, 8, + color=self.theme["colors"]["text"]["body"]) + else: + callout_left_x = left + add_line(slide, lvl_left, lvl_top + level_h / 2, + callout_left_x + width * 0.25, + lvl_top + level_h / 2, "#48545a", 0.02) + add_text_box(slide, callout_left_x, + lvl_top + level_h / 2 - 0.20, + width * 0.24, 0.60, + level["description"], + self._font_body, 8, + color=self.theme["colors"]["text"]["body"], + align=PP_ALIGN.RIGHT) + + # C17 — circular_segment ─────────────────────────────────────────────── + + def _render_circular_diagram(self, slide, zone, slide_data, + left, top, width, height): + """Diagramme circulaire (approximation par secteurs via rectangles colorés).""" + segments = slide_data.get("segments", []) + if not segments: + return + + colors_default = self.theme["colors"]["cycle"] + n = len(segments) + + # Cercle central approximé (zones colorées en 2×N) + # Note : python-pptx ne supporte pas les pie charts custom facilement. + # On utilise des ellipses + médaillon central. + cx = left + width * 0.35 + cy = top + height / 2 + r = min(height * 0.38, width * 0.25) + + # Secteurs simulés par des rectangles colorés en arc + # (approximation visuelle — pour un vrai pie, utiliser chart_data) + angle_step = 360 / n + for i, seg in enumerate(segments): + color = self._r(seg.get("couleur") or colors_default[i % len(colors_default)]) + # Ellipse approximant un secteur + angle_rad = math.radians(i * angle_step) + sx = cx + r * 0.5 * math.cos(angle_rad) + sy = cy + r * 0.5 * math.sin(angle_rad) + shape = slide.shapes.add_shape(9, + cm(sx - r * 0.45), cm(sy - r * 0.45), + cm(r * 0.90), cm(r * 0.90)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(color) + shape.line.fill.background() + + # Médaillon central blanc + shape = slide.shapes.add_shape(9, + cm(cx - r * 0.35), cm(cy - r * 0.35), + cm(r * 0.70), cm(r * 0.70)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb("#ffffff") + shape.line.fill.background() + + # Légende à droite + leg_left = left + width * 0.55 + leg_top = top + (height - n * 1.4) / 2 + + for i, seg in enumerate(segments): + color = self._r(seg.get("couleur") or colors_default[i % len(colors_default)]) + y = leg_top + i * 1.40 + + # Pastille + shape = slide.shapes.add_shape(9, + cm(leg_left), cm(y + 0.05), + cm(0.35), cm(0.35)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(color) + shape.line.fill.background() + + # Num + label + add_text_box(slide, leg_left + 0.50, y, + width - (leg_left - left) - 0.60, 0.40, + f"0{i+1} {seg.get('label', '')}", + self._font_body, 10, bold=True, + color=self.theme["colors"]["primary"]["dark_blue"]) + if seg.get("description"): + add_text_box(slide, leg_left + 0.50, y + 0.42, + width - (leg_left - left) - 0.60, 0.70, + seg["description"], + self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # C18 — from_to_pair ─────────────────────────────────────────────────── + + def _render_from_to_pairs(self, slide, zone, slide_data, + left, top, width, height): + """Paires FROM → TO.""" + pairs = slide_data.get("pairs", []) + if not pairs: + return + + # En-tête FROM / TO + mid_x = left + width * 0.42 + add_text_box(slide, left, top, width * 0.40, 0.50, + "FROM", self._font_body, 11, + bold=True, + color=self.theme["colors"]["semantic"]["arrow_color"]) + add_text_box(slide, mid_x + 0.80, top, width * 0.40, 0.50, + "TO", self._font_body, 11, + bold=True, + color=self.theme["colors"]["semantic"]["arrow_color"]) + + row_h = (height - 0.60) / max(len(pairs), 1) + for i, pair in enumerate(pairs): + y = top + 0.60 + i * row_h + # FROM (atténué) + add_text_box(slide, left, y + 0.08, + width * 0.38, row_h - 0.15, + pair.get("from", ""), + self._font_body, 11, + color=self.theme["colors"]["semantic"]["from_color"]) + + # Flèche + add_text_box(slide, mid_x - 0.20, y + 0.05, 0.60, 0.40, + "›", self._font_body, 18, bold=True, + color=self.theme["colors"]["semantic"]["arrow_color"], + align=PP_ALIGN.CENTER) + + # TO (affirmé) + add_text_box(slide, mid_x + 0.50, y + 0.08, + width - mid_x - 0.50, row_h - 0.15, + pair.get("to", ""), + self._font_body, 11, + bold=True, + color=self.theme["colors"]["semantic"]["to_color"]) + + # Séparateur + if i < len(pairs) - 1: + add_line(slide, left, y + row_h, + left + width, y + row_h, + "#e8e2d6", 0.02) + + # C19 — step_item ────────────────────────────────────────────────────── + + def _render_numbered_steps(self, slide, zone, slide_data, + left, top, width, height): + """Étapes numérotées verticalement.""" + steps = slide_data.get("steps", []) + if not steps: + return + + row_h = height / max(len(steps), 1) + badge_size = 0.70 + + for i, step in enumerate(steps): + y = top + i * row_h + + # Badge carré + add_rect(slide, left, y + (row_h - badge_size) / 2, + badge_size, badge_size, + self.theme["colors"]["primary"]["dark_blue"]) + add_text_box(slide, left, y + (row_h - badge_size) / 2, + badge_size, badge_size, + str(step.get("numero", i + 1)), + self._font_body, 11, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + + # Titre + add_text_box(slide, left + badge_size + 0.25, + y + (row_h - badge_size) / 2, + width * 0.35, badge_size, + step.get("titre", ""), + self._font_body, 13, + bold=True, + color=self.theme["colors"]["primary"]["dark_blue"]) + + # Description + if step.get("description"): + add_text_box(slide, left + badge_size + 0.25 + width * 0.36, + y + (row_h - badge_size) / 2, + width - badge_size - 0.25 - width * 0.36, + badge_size, + step["description"], + self._font_body, 10, + color=self.theme["colors"]["text"]["body"]) + + # Séparateur + if i < len(steps) - 1: + add_line(slide, left, y + row_h, + left + width, y + row_h, + "#e8e2d6", 0.02) + + # C20 — chevron_step ─────────────────────────────────────────────────── + + def _render_chevrons(self, slide, zone, slide_data, + left, top, width, height): + """Chevrons horizontaux de process.""" + phases = slide_data.get("phases", []) + if not phases: + return + + n = len(phases) + tip = 0.40 # largeur de la pointe + chev_h = 1.00 + total_w = width - 0.50 + chev_w = total_w / n + bullets_top = top + chev_h + 0.30 + + for i, phase in enumerate(phases): + x = left + i * chev_w + is_active = phase.get("actif", False) + is_last = (i == n - 1) + + fill = (self.theme["colors"]["primary"]["dark_blue"] + if is_active else + self.theme["colors"]["primary"]["hague_grey"]) + + # Rectangle du chevron + add_rect(slide, x, top, chev_w - 0.10, chev_h, fill) + # Texte + add_text_box(slide, x + 0.20, top + 0.20, + chev_w - 0.60, 0.60, + phase.get("label", ""), + self._font_display, 13, + bold=True, + color=self.theme["colors"]["primary"]["rose"] + if is_active else "#ffffff", + align=PP_ALIGN.CENTER) + + # Durée sous le chevron + if phase.get("duree"): + add_text_box(slide, x, top + chev_h + 0.05, + chev_w, 0.30, + phase["duree"], + self._font_body, 8, + color=self.theme["colors"]["text"]["body"], + align=PP_ALIGN.CENTER) + + # Bullets sous la phase + if phase.get("bullets"): + for j, bullet in enumerate(phase["bullets"]): + add_text_box(slide, x + 0.15, + bullets_top + j * 0.55, + chev_w - 0.30, 0.50, + "• " + bullet, + self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # C21 — gantt_bar ────────────────────────────────────────────────────── + + def _render_gantt(self, slide, zone, slide_data, + left, top, width, height): + """Gantt simplifié.""" + period = slide_data.get("period", {}) + workstreams = slide_data.get("workstreams", []) + if not workstreams: + return + + label_w = zone.get("label_col_width_cm", 5.50) + header_h = zone.get("header_height_cm", 0.60) + stream_h = zone.get("workstream_height_cm", 2.80) + timeline_w = width - label_w + + # Parse période + def parse_ym(s): + parts = str(s).split("-") + return int(parts[0]) * 12 + int(parts[1]) if len(parts) == 2 else 0 + + p_start = parse_ym(period.get("start", "2026-01")) + p_end = parse_ym(period.get("end", "2026-12")) + total_months = max(p_end - p_start + 1, 1) + + # Header mois + import calendar + months_short = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + add_rect(slide, left + label_w, top, timeline_w, header_h, + self.theme["colors"]["backgrounds"]["content_area"]) + + for m in range(total_months): + mx = left + label_w + (m / total_months) * timeline_w + mw = timeline_w / total_months + ym = p_start + m + month_name = months_short[(ym - 1) % 12] + add_text_box(slide, mx, top + 0.08, mw, 0.40, + month_name, self._font_body, 7, + color="#48545a", align=PP_ALIGN.CENTER) + + colors = self.theme["colors"]["cycle"] + + for i, ws in enumerate(workstreams): + y = top + header_h + i * stream_h + color = colors[i % len(colors)] + + # Label workstream (optionnel) + if ws.get("label"): + add_rect(slide, left, y, label_w - 0.20, stream_h - 0.10, + self.theme["colors"]["backgrounds"]["content_area"]) + add_text_box(slide, left + 0.15, y + stream_h / 2 - 0.20, + label_w - 0.40, 0.40, + ws["label"], self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + else: + add_rect(slide, left, y, label_w - 0.20, stream_h - 0.10, + "#e8e2d6") + + # Barres de tâches + for task in ws.get("tasks", []): + t_start = parse_ym(task.get("start", period.get("start"))) + t_end = parse_ym(task.get("end", period.get("end"))) + row = task.get("row", 1) + + offset_x = ((t_start - p_start) / total_months) * timeline_w + bar_w = max(((t_end - t_start + 1) / total_months) * timeline_w, 0.30) + bar_y = y + (row - 1) * (stream_h / 2) + 0.25 + bar_h = stream_h / 2 - 0.35 + + tc = self._r(task.get("couleur") or color) + add_rect(slide, left + label_w + offset_x, bar_y, + bar_w, bar_h, tc) + + # C22 — timeline_milestone ───────────────────────────────────────────── + + def _render_timeline(self, slide, zone, slide_data, + left, top, width, height): + """Timeline horizontale (yearly ou phases).""" + milestones = slide_data.get("milestones", []) + if not milestones: + # phases_timeline variant + self._render_phases_timeline(slide, zone, slide_data, left, top, width, height) + return + + n = len(milestones) + axis_y = top + height / 2 + spacing = width / (n + 1) + + # Axe + add_line(slide, left, axis_y, left + width, axis_y, "#48545a", 0.04) + # Flèche → + add_text_box(slide, left + width - 0.30, axis_y - 0.20, + 0.40, 0.40, "→", self._font_body, 10, color="#48545a") + + colors = self.theme["colors"] + for i, m in enumerate(milestones): + mx = left + (i + 1) * spacing + is_active = m.get("actif", False) + circle_color = (colors["primary"]["rose"] if is_active + else colors["primary"]["dark_blue"]) + year_color = (colors["primary"]["rose"] if is_active + else colors["primary"]["bright_blue"]) + + # Cercle sur l'axe + r = 0.18 + shape = slide.shapes.add_shape(9, + cm(mx - r), cm(axis_y - r), cm(r * 2), cm(r * 2)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(circle_color) + shape.line.fill.background() + + # Année au-dessus + add_text_box(slide, mx - 1.0, axis_y - 1.20, + 2.0, 0.50, str(m.get("annee", "")), + self._font_display, 13, + bold=True, color=year_color, + align=PP_ALIGN.CENTER) + + # Label + add_text_box(slide, mx - 1.5, axis_y + 0.30, + 3.0, 0.40, m.get("label", ""), + self._font_body, 9, + bold=True if is_active else False, + color=colors["primary"]["dark_blue"], + align=PP_ALIGN.CENTER) + + # Description + if m.get("description"): + add_text_box(slide, mx - 1.5, axis_y + 0.75, + 3.0, 0.70, m["description"], + self._font_body, 8, + color=colors["text"]["body"], + align=PP_ALIGN.CENTER) + + def _render_phases_timeline(self, slide, zone, slide_data, + left, top, width, height): + """Phases horizontales contiguës (L24).""" + phases = slide_data.get("phases", []) + if not phases: + return + + n = len(phases) + phase_h = 0.65 + period_h = 0.40 + colors = self.theme["colors"]["cycle"] + + # Largeur proportionnelle ou égale + phase_w = width / n + + for i, phase in enumerate(phases): + x = left + i * phase_w + color = colors[i % len(colors)] + + add_rect(slide, x, top, phase_w - 0.10, phase_h, color) + add_text_box(slide, x + 0.10, top + 0.10, + phase_w - 0.20, phase_h - 0.15, + phase.get("label", ""), + self._font_body, 8, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + + if phase.get("periode"): + add_text_box(slide, x, top + phase_h + 0.05, + phase_w, period_h, + phase["periode"], + self._font_body, 7, + color=self._r(color), + align=PP_ALIGN.CENTER) + + items = phase.get("items", []) + for j, item in enumerate(items): + add_text_box(slide, x + 0.10, + top + phase_h + period_h + 0.20 + j * 0.55, + phase_w - 0.20, 0.50, + "• " + item, + self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # C23 — org_node ─────────────────────────────────────────────────────── + + def _render_org_chart(self, slide, zone, slide_data, + left, top, width, height): + """Organigramme hiérarchique top-down.""" + root = slide_data.get("root", {}) + if not root: + return + + colors_by_level = [ + self.theme["colors"]["primary"]["dark_blue"], + self.theme["colors"]["secondary"]["barley_green"], + self.theme["colors"]["secondary"]["cool_blue"], + self.theme["colors"]["primary"]["bright_warm"], + ] + text_by_level = ["#ffffff", "#ffffff", "#000a32", "#000a32"] + + node_h = 0.65 + level_gap = 1.20 + + def draw_tree(node, level, x_center, y): + color = colors_by_level[min(level, len(colors_by_level) - 1)] + txt_color = text_by_level[min(level, len(text_by_level) - 1)] + node_w = max(3.0 - level * 0.3, 2.0) + + nx = x_center - node_w / 2 + add_rect(slide, nx, y, node_w, node_h, color) + add_text_box(slide, nx + 0.10, y + 0.12, + node_w - 0.20, node_h - 0.15, + node.get("label", ""), + self._font_body, 8, + bold=True, color=txt_color, + align=PP_ALIGN.CENTER) + + children = node.get("children", []) + if not children: + return + + nc = len(children) + child_span = min(width / max(nc, 1), 6.0) + children_total_w = child_span * nc + child_start_x = x_center - children_total_w / 2 + child_span / 2 + + child_y = y + node_h + level_gap + + # Ligne verticale descendante + add_line(slide, x_center, y + node_h, + x_center, y + node_h + level_gap / 2, + "#48545a", 0.03) + + # Ligne horizontale + add_line(slide, + child_start_x, y + node_h + level_gap / 2, + child_start_x + children_total_w - child_span, + y + node_h + level_gap / 2, + "#48545a", 0.03) + + for i, child in enumerate(children): + cx = child_start_x + i * child_span + add_line(slide, cx, y + node_h + level_gap / 2, + cx, child_y, "#48545a", 0.03) + draw_tree(child, level + 1, cx, child_y) + + draw_tree(root, 0, left + width / 2, top) + + # C24 — raci_cell ────────────────────────────────────────────────────── + + def _render_raci(self, slide, zone, slide_data, + left, top, width, height): + """Matrice RACI.""" + roles = slide_data.get("roles", []) + tasks = slide_data.get("tasks", []) + if not roles or not tasks: + return + + task_col_w = zone.get("task_col_width_cm", 8.00) + header_h = 0.65 + role_col_w = (width - task_col_w) / max(len(roles), 1) + row_h = min((height - header_h) / max(len(tasks), 1), 0.80) + + raci_colors = { + "R": self.theme["colors"]["semantic"]["responsible"], + "A": self.theme["colors"]["semantic"]["accountable"], + "C": self.theme["colors"]["semantic"]["consulted"], + "I": self.theme["colors"]["semantic"]["informed"], + } + + # Header + add_rect(slide, left, top, task_col_w, header_h, + self.theme["colors"]["backgrounds"]["content_area"]) + for j, role in enumerate(roles): + x = left + task_col_w + j * role_col_w + add_rect(slide, x, top, role_col_w, header_h, + self.theme["colors"]["primary"]["dark_blue"]) + add_text_box(slide, x, top + 0.10, role_col_w, 0.45, + role, self._font_body, 9, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + + for i, task in enumerate(tasks): + y = top + header_h + i * row_h + bg = ("#ffffff" if i % 2 == 0 + else self.theme["colors"]["backgrounds"]["content_area"]) + + add_rect(slide, left, y, task_col_w, row_h, bg) + add_text_box(slide, left + 0.20, y + 0.12, + task_col_w - 0.30, row_h - 0.15, + task.get("label", ""), + self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + raci_vals = task.get("raci", []) + for j in range(len(roles)): + x = left + task_col_w + j * role_col_w + add_rect(slide, x, y, role_col_w, row_h, bg) + + val = raci_vals[j] if j < len(raci_vals) else "" + if val in raci_colors: + r_d = 0.35 + r_x = x + role_col_w / 2 - r_d / 2 + r_y = y + row_h / 2 - r_d / 2 + opacity = 1.0 if val in ("R", "A", "C") else 0.45 + shape = slide.shapes.add_shape(9, + cm(r_x), cm(r_y), cm(r_d), cm(r_d)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(raci_colors[val]) + shape.line.fill.background() + + add_text_box(slide, r_x, r_y + 0.04, + r_d, r_d - 0.08, + val, self._font_body, 8, + bold=True, + color="#ffffff" if val in ("R", "A") else "#000a32", + align=PP_ALIGN.CENTER) + + add_line(slide, left, y + row_h, left + width, y + row_h, "#e8e2d6", 0.02) + + # C25 — decision_node ────────────────────────────────────────────────── + + def _render_decision_tree(self, slide, zone, slide_data, + left, top, width, height): + """Arbre de décision YES/NO.""" + question = slide_data.get("question", "") + branches = slide_data.get("branches", {}) + + # Question centrale + q_w, q_h = 7.0, 2.80 + q_x = left + 0.50 + q_y = top + height / 2 - q_h / 2 + + add_rect(slide, q_x, q_y, q_w, q_h, + self.theme["colors"]["backgrounds"]["content_area"], + "#48545a", 0.03) + add_text_box(slide, q_x + 0.30, q_y + 0.30, + q_w - 0.60, q_h - 0.60, + question, self._font_body, 10, + color=self.theme["colors"]["primary"]["dark_blue"]) + + branch_configs = [ + ("yes", "YES", top + height * 0.20), + ("no", "NO", top + height * 0.65), + ] + colors_branch = { + "yes": self.theme["colors"]["primary"]["bright_warm"], + "no": self.theme["colors"]["primary"]["rose"], + } + + for key, label, branch_y in branch_configs: + branch = branches.get(key, {}) + if not branch: + continue + + # Connecteur + label YES/NO + add_line(slide, q_x + q_w, q_y + q_h / 2, + left + q_w + 2.0, branch_y + 1.0, + "#48545a", 0.03) + add_text_box(slide, q_x + q_w + 0.20, + (q_y + q_h / 2 + branch_y + 1.0) / 2 - 0.15, + 0.80, 0.30, label, + self._font_body, 8, bold=True, + color="#48545a") + + # Nœud branche + b_x = left + q_w + 2.0 + b_w, b_h = 6.0, 2.20 + branch_color = colors_branch.get(key, "#d9d9c4") + add_rect(slide, b_x, branch_y, b_w, b_h, + branch_color, "#48545a", 0.03) + add_text_box(slide, b_x + 0.25, branch_y + 0.25, + b_w - 0.50, b_h - 0.50, + branch.get("label", ""), + self._font_body, 10, + color=self.theme["colors"]["primary"]["dark_blue"]) + + # Options terminales + options = branch.get("options", []) + opt_x = b_x + b_w + 0.80 + opt_w = left + width - opt_x - 0.20 + for k, opt in enumerate(options[:2]): + opt_y = branch_y + k * 1.20 + add_line(slide, b_x + b_w, branch_y + b_h / 2, + opt_x, opt_y + 0.40, "#48545a", 0.02) + add_rect(slide, opt_x, opt_y, opt_w, 1.0, + self.theme["colors"]["backgrounds"]["content_area"], + "#e8e2d6", 0.02) + add_text_box(slide, opt_x + 0.20, opt_y + 0.15, + opt_w - 0.40, 0.70, + opt, self._font_body, 9, + color=self.theme["colors"]["primary"]["dark_blue"]) + + # C26 — recommendation_sidebar ───────────────────────────────────────── + + def _render_recommendation_sidebar(self, slide, zone, slide_data, + left, top, width, height): + """Sidebar jaune + corps de la recommandation.""" + numero = slide_data.get("numero", 1) + titre = slide_data.get("titre", "") + resume = slide_data.get("resume", "") + cta = slide_data.get("cta", "") + headline = slide_data.get("headline", "") + bullets = slide_data.get("bullets", []) + + # Sidebar fond jaune + sidebar_w = width # width = 8.0 cm (défini dans layouts.yaml) + add_rect(slide, left, top, sidebar_w, height, + self.theme["colors"]["backgrounds"]["highlight_box"]) + + # Cercle numéro + r = 0.55 + cx = left + sidebar_w / 2 + shape = slide.shapes.add_shape(9, + cm(cx - r), cm(top + 1.0), cm(r * 2), cm(r * 2)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb( + self.theme["colors"]["primary"]["dark_blue"]) + shape.line.fill.background() + add_text_box(slide, cx - r, top + 1.0, r * 2, r * 2, + str(numero), self._font_display, 18, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + + # Titre sidebar + add_text_box(slide, left + 0.30, top + 2.30, + sidebar_w - 0.60, 1.50, + titre, self._font_display, 16, + bold=True, + color=self.theme["colors"]["primary"]["dark_blue"], + align=PP_ALIGN.CENTER) + + # Résumé + if resume: + add_text_box(slide, left + 0.30, top + 4.0, + sidebar_w - 0.60, 3.0, + resume, self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # CTA + if cta: + add_rect(slide, left + 0.30, top + height - 2.0, + sidebar_w - 0.60, 0.80, + self.theme["colors"]["primary"]["dark_blue"]) + add_text_box(slide, left + 0.30, top + height - 2.0, + sidebar_w - 0.60, 0.80, + cta, self._font_body, 9, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + + # Contenu principal à droite de la sidebar + content_left = left + sidebar_w + 0.50 + content_w = 33.87 - content_left - 0.50 + + # Header band + if headline: + add_rect(slide, content_left, top + 0.80, + content_w, 1.10, + self.theme["colors"]["primary"]["dark_blue"]) + add_text_box(slide, content_left + 0.30, top + 1.0, + content_w - 0.60, 0.70, + headline, self._font_body, 11, + bold=True, color="#ffffff") + + # Bullets + if bullets: + zone_fake = {"id": "main_content", "component": "C06", + "width_cm": content_w} + slide_fake = {"bullets": bullets} + self._render_bullet_list(slide, zone_fake, slide_fake, + content_left, top + 2.20, + content_w, height - 2.50) + + +# ───────────────────────────────────────────────────────────────────────────── +# CLI +# ───────────────────────────────────────────────────────────────────────────── + +def main(): + import argparse + parser = argparse.ArgumentParser( + description="Sliding render_engine — JSON → PPTX Pernod Ricard") + parser.add_argument("json_file", + help="Fichier JSON de la présentation (sortie Agent 3)") + parser.add_argument("output", + help="Chemin du fichier PPTX à générer") + parser.add_argument("--theme", + default="theme.yaml", + help="Chemin vers theme.yaml (défaut: ./theme.yaml)") + parser.add_argument("--components", + default="components.yaml", + help="Chemin vers components.yaml") + parser.add_argument("--layouts", + default="layouts.yaml", + help="Chemin vers layouts.yaml") + args = parser.parse_args() + + if not os.path.exists(args.json_file): + print(f"✗ Fichier JSON introuvable : {args.json_file}") + sys.exit(1) + for f in [args.theme, args.components, args.layouts]: + if not os.path.exists(f): + print(f"✗ Fichier YAML introuvable : {f}") + sys.exit(1) + + engine = RenderEngine(args.theme, args.components, args.layouts) + with open(args.json_file, encoding="utf-8") as f: + json_data = json.load(f) + engine.render(json_data, args.output) + + +if __name__ == "__main__": + main() diff --git a/archive/render_engine_v1.1.py b/archive/render_engine_v1.1.py new file mode 100644 index 0000000..fe6b931 --- /dev/null +++ b/archive/render_engine_v1.1.py @@ -0,0 +1,1924 @@ +""" +render_engine.py — Sliding Design System · Pernod Ricard +========================================================= +Moteur de rendu générique JSON → PPTX. + +Usage : + from render_engine import RenderEngine + engine = RenderEngine("theme.yaml", "components.yaml", "layouts.yaml") + engine.render(json_data, "output.pptx") + +Ou en ligne de commande : + python render_engine.py presentation.json output.pptx + +Architecture : + RenderEngine.render() + └── pour chaque slide : + 1. _resolve_layout() → charge la config du layout + 2. _render_background() → fond (C01) + 3. _render_signature() → logo, barre d'accent, footer (C02-C05) + 4. _measure_title() → calcule hauteur réelle du titre + 5. _render_title() → place le titre (C02) + 6. pour chaque content_zone : + _measure_component() → hauteur réelle + _render_component() → dispatch vers le bon renderer +""" + +from __future__ import annotations + +import json +import math +import os +import sys +from pathlib import Path +from typing import Any + +import yaml +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.text import PP_ALIGN +from pptx.util import Cm, Pt, Emu +from pptx.dml.color import RGBColor +from pptx.oxml.ns import qn +from lxml import etree + + +# ───────────────────────────────────────────────────────────────────────────── +# CONSTANTES +# ───────────────────────────────────────────────────────────────────────────── + +CM = 360000 # 1 cm = 360 000 EMU +PT = 12700 # 1 pt = 12 700 EMU +SLIDE_W = 12192000 # 33.87 cm +SLIDE_H = 6858000 # 19.05 cm +FOOTER_TOP = 18.35 # cm +FOOTER_H = 0.70 # cm + + +# ───────────────────────────────────────────────────────────────────────────── +# UTILITAIRES +# ───────────────────────────────────────────────────────────────────────────── + +def cm(v: float) -> int: + """Centimètres → EMU.""" + return int(v * CM) + + +def pt(v: float) -> int: + """Points → EMU (pour line_spacing, etc.).""" + return int(v * PT) + + +def hex_to_rgb(h: str) -> RGBColor: + """'#rrggbb' → RGBColor.""" + h = h.lstrip("#") + return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) + + +def resolve_ref(value: str, theme: dict) -> str: + """ + Résout une référence theme.* dans une valeur YAML. + Ex: 'theme.colors.primary.rose' → '#ff9166' + Retourne la valeur brute si ce n'est pas une ref. + """ + if not isinstance(value, str) or not value.startswith("theme."): + return value + parts = value.split(".")[1:] # retire 'theme' + node = theme + for p in parts: + if isinstance(node, dict) and p in node: + node = node[p] + else: + return value # ref non résolue → retourne telle quelle + return node + + +def add_text_box(slide, left, top, width, height, + text, font_name, font_size_pt, bold=False, italic=False, + color="#000000", align=PP_ALIGN.LEFT, word_wrap=True): + """Ajoute une text box sur le slide. Retourne le shape.""" + txBox = slide.shapes.add_textbox(cm(left), cm(top), cm(width), cm(height)) + tf = txBox.text_frame + tf.word_wrap = word_wrap + p = tf.paragraphs[0] + p.alignment = align + run = p.add_run() + run.text = text + run.font.name = font_name + run.font.size = Pt(font_size_pt) + run.font.bold = bold + run.font.italic = italic + run.font.color.rgb = hex_to_rgb(color) + return txBox + + +def add_rect(slide, left, top, width, height, fill_color, border_color=None, border_width_cm=0): + """Ajoute un rectangle plein. Retourne le shape.""" + shape = slide.shapes.add_shape( + 1, # MSO_SHAPE_TYPE.RECTANGLE + cm(left), cm(top), cm(width), cm(height) + ) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(fill_color) + if border_color and border_width_cm > 0: + shape.line.color.rgb = hex_to_rgb(border_color) + shape.line.width = cm(border_width_cm) + else: + shape.line.fill.background() + return shape + + +def add_line(slide, x1, y1, x2, y2, color="#e8e2d6", width_cm=0.03): + """Ajoute une ligne.""" + from pptx.util import Emu + connector = slide.shapes.add_connector(1, cm(x1), cm(y1), cm(x2), cm(y2)) + connector.line.color.rgb = hex_to_rgb(color) + connector.line.width = cm(width_cm) + return connector + + +def estimate_text_height(text: str, font_size_pt: float, + box_width_cm: float, line_spacing: float = 1.15) -> float: + """ + Estime la hauteur en cm d'un texte dans une boîte. + Heuristique : ~2.2 caractères par cm de largeur à 11pt, scaled par font_size. + """ + chars_per_line = max(1, int(box_width_cm * 2.2 * (11 / font_size_pt))) + lines = 0 + for paragraph in text.split("\n"): + if not paragraph.strip(): + lines += 0.5 + continue + lines += math.ceil(len(paragraph) / chars_per_line) + line_height_cm = font_size_pt * 0.035 * line_spacing + return lines * line_height_cm + + +# ───────────────────────────────────────────────────────────────────────────── +# RENDER ENGINE +# ───────────────────────────────────────────────────────────────────────────── + +class RenderEngine: + """ + Moteur principal. Charge les 3 YAML, expose render(json_data, output_path). + """ + + def __init__(self, theme_path: str, components_path: str, layouts_path: str): + with open(theme_path, encoding="utf-8") as f: + self.theme = yaml.safe_load(f) + with open(components_path, encoding="utf-8") as f: + self.components = yaml.safe_load(f)["components"] + with open(layouts_path, encoding="utf-8") as f: + data = yaml.safe_load(f) + self.layouts = data["layouts"] + + # Polices résolues (avec fallback si non installées) + self._font_display = self._resolve_font("display") + self._font_body = self._resolve_font("body") + + # Cycle couleur (index global, remis à zéro par présentation) + self._cycle_index = 0 + + # ── Résolution des polices ───────────────────────────────────────────── + + def _resolve_font(self, role: str) -> str: + """Retourne le nom de police à utiliser (tentative + fallback).""" + font_cfg = self.theme["typography"][role] + primary = font_cfg["family"] + fallback = font_cfg["fallback"] + # On tente d'utiliser la police primaire. python-pptx l'intègre par + # nom — si elle n'est pas installée sur la machine cible, PowerPoint + # utilisera la police système la plus proche. + return primary # fallback géré à l'ouverture du PPTX côté utilisateur + + def _font(self, role: str) -> str: + return self._font_display if role == "display" else self._font_body + + # ── Résolution des refs theme ────────────────────────────────────────── + + def _r(self, value: Any) -> Any: + """Résout une ref theme.* si nécessaire.""" + return resolve_ref(value, self.theme) + + def _cycle_color(self) -> str: + colors = self.theme["colors"]["cycle"] + c = colors[self._cycle_index % len(colors)] + self._cycle_index += 1 + return c + + # ── Mesure ──────────────────────────────────────────────────────────── + + def _measure_title(self, layout_cfg: dict, slide_data: dict) -> float: + """ + Calcule la hauteur réelle occupée par le bloc titre + sous-titre. + Retourne la hauteur en cm. + """ + tz = layout_cfg.get("title_zone") + if not tz: + return 0.0 + + titre = slide_data.get("titre", "") + sous_titre = slide_data.get("sous_titre", "") + + font_override = tz.get("font_override", {}) + size_pt = font_override.get("size_pt", + self.theme["typography"]["sizes"]["slide_title"]) + width_cm = tz.get("width_cm", 30.0) + + h = estimate_text_height(titre, size_pt, width_cm, 1.1) + if sous_titre: + sub_size = tz.get("subtitle", {}).get("size_pt", + self.theme["typography"]["sizes"]["slide_subtitle"]) + h += estimate_text_height(sous_titre, sub_size, width_cm, 1.1) + h += 0.15 # marge entre titre et sous-titre + return max(h, 0.60) # minimum 0.6 cm + + def _measure_component(self, zone: dict, slide_data: dict) -> float: + """ + Estime la hauteur réelle d'une content_zone selon son contenu. + Retourne la hauteur en cm. Si non estimable, retourne height_cm du layout. + """ + comp_id = zone.get("component", "") + max_h = zone.get("height_cm", 14.0) + + # bullet_list + if comp_id == "C06": + bullets = slide_data.get("bullets", []) + if not bullets: + # cherche dans les sous-clés (two_cols, etc.) + return max_h + total_h = 0.0 + for b in bullets: + lvl = b.get("niveau", 1) + size_pt = [11, 10, 9][min(lvl - 1, 2)] + w = zone.get("width_cm", 28.0) + total_h += estimate_text_height( + b.get("texte", ""), size_pt, w - (lvl - 1) * 0.5) + total_h += [0.14, 0.08, 0.04][min(lvl - 1, 2)] + return min(total_h + 0.3, max_h) + + # text_paragraph (executive_summary blocs) + if comp_id == "C07": + # cherche le champ associé dans slide_data + for key in ["situation", "complication", "resolution", + "contenu", "description"]: + if key in slide_data: + txt = slide_data[key] + h = estimate_text_height(txt, 11, zone.get("width_cm", 28.0)) + return min(h + 0.6, max_h) # +0.6 pour le titre de bloc + return max_h + + # kpi_grid → hauteur calculée selon nb items + if comp_id == "C09": + items = slide_data.get("items", []) + n = len(items) + grid = {2: (2, 1), 3: (3, 1), 4: (2, 2), 5: (3, 2), 6: (3, 2)} + cols, rows = grid.get(n, (3, 2)) + card_h = 4.5 # hauteur d'une carte KPI en cm + gap = 0.4 + return min(rows * card_h + (rows - 1) * gap, max_h) + + # big_stat → hauteur fixe + if comp_id == "C10": + return max_h + + # Pour tous les autres composants visuels complexes → hauteur max + return max_h + + # ── Rendu principal ─────────────────────────────────────────────────── + + def render(self, json_data: dict | str, output_path: str): + """ + Point d'entrée. Accepte un dict ou une chaîne JSON. + Produit le fichier PPTX à output_path. + """ + if isinstance(json_data, str): + json_data = json.loads(json_data) + + prs = Presentation() + prs.slide_width = Emu(SLIDE_W) + prs.slide_height = Emu(SLIDE_H) + + # Supprime les layouts par défaut (on dessine tout manuellement) + blank_layout = prs.slide_layouts[6] # layout "blank" + + self._cycle_index = 0 + slides = json_data.get("slides", []) + + for i, slide_data in enumerate(slides): + slide = prs.slides.add_slide(blank_layout) + layout_name = slide_data.get("layout", "default_bullets") + self._render_slide(slide, slide_data, layout_name, i + 1, len(slides)) + + prs.save(output_path) + print(f"✓ PPTX généré : {output_path} ({len(slides)} slides)") + + def _render_slide(self, slide, slide_data: dict, layout_name: str, + slide_num: int, total: int): + """Orchestre le rendu d'un slide complet.""" + layout_cfg = self.layouts.get(layout_name) + if not layout_cfg: + print(f" ⚠ Layout inconnu '{layout_name}' → fallback default_bullets") + layout_cfg = self.layouts["default_bullets"] + layout_name = "default_bullets" + + # ── 1. Background ───────────────────────────────────────────────── + self._render_background(slide, layout_cfg) + + # ── 2. Signature (footer, logo, accent bar) ─────────────────────── + self._render_footer(slide, layout_name, slide_num) + self._render_logo(slide, layout_name) + + # ── 3. Titre + mesure ───────────────────────────────────────────── + title_h = self._measure_title(layout_cfg, slide_data) + title_bottom = self._render_title(slide, layout_cfg, slide_data, title_h) + self._render_accent_bar(slide, layout_name, title_h) + + # ── 4. Content zones ────────────────────────────────────────────── + zones = layout_cfg.get("content_zones") or [] + # cursor : commence juste sous le titre + cursor_y = title_bottom + 0.20 if title_bottom else 2.80 + # zone max disponible (jusqu'au footer ou bas du slide) + max_bottom = FOOTER_TOP - 0.30 # laisse 0.3 cm au-dessus du footer + + for zone in zones: + # Zones optionnelles absentes du JSON → skip + if zone.get("optional") and not self._zone_has_data(zone, slide_data): + continue + + # Positions : priorité aux coords fixes, sinon on utilise le curseur + z_left = zone.get("left_cm", 1.50) + z_top = zone.get("top_cm", cursor_y) + z_width = zone.get("width_cm", 30.87) + + # Calcul de la hauteur réelle + measured_h = self._measure_component(zone, slide_data) + z_height = min(measured_h, max_bottom - z_top) + if z_height <= 0: + continue # plus de place + + # Mise à jour du curseur (uniquement pour les zones sans top fixe) + if "top_cm" not in zone: + cursor_y = z_top + z_height + 0.25 + + self._render_zone(slide, zone, slide_data, + z_left, z_top, z_width, z_height) + + # ── Background ──────────────────────────────────────────────────────── + + def _render_background(self, slide, layout_cfg: dict): + """Rend le fond du slide (C01).""" + bg = layout_cfg.get("background", {}) + color = self._r(bg.get("color", "#ffffff")) + + if bg.get("diagonal_split"): + color_right = self._r(bg.get("color_right", "#023466")) + angle = bg.get("diagonal_angle_deg", 15) + self._render_diagonal_background(slide, color, color_right, angle) + else: + add_rect(slide, 0, 0, 33.87, 19.05, color) + + def _render_diagonal_background(self, slide, color_left: str, + color_right: str, angle_deg: float): + """Fond splitté diagonal : rectangle gauche + triangle droit.""" + # Panneau gauche plein + add_rect(slide, 0, 0, 33.87, 19.05, color_left) + # Panneau droit via freeform (triangle) + # La diagonale va du point (split_x, 0) au point (split_x - offset, 19.05) + split_x = 20.0 # cm — point haut de la diagonale + offset = 19.05 * math.tan(math.radians(angle_deg)) + split_x_bottom = split_x - offset + + from pptx.util import Emu + from pptx.oxml.ns import qn + + # Utilise add_shape freeform via XML pour le triangle + sp = slide.shapes.add_shape(1, + cm(split_x_bottom), cm(0), + cm(33.87 - split_x_bottom), cm(19.05)) + sp.fill.solid() + sp.fill.fore_color.rgb = hex_to_rgb(color_right) + sp.line.fill.background() + + # Note : python-pptx ne supporte pas les freeforms nativement. + # Pour un vrai triangle, il faudrait manipuler l'XML OOXML directement. + # Cette version utilise un rectangle approché — suffisant pour l'aperçu. + # TODO : implémenter la forme triangulaire via lxml si rendu exact requis. + + # ── Signature ───────────────────────────────────────────────────────── + + def _render_footer(self, slide, layout_name: str, slide_num: int): + """Rend le footer PR (C04).""" + footer_cfg = self.theme["signature"]["footer"] + hidden_on = footer_cfg.get("hidden_on", []) + if layout_name in hidden_on: + return + + top = FOOTER_TOP + h = FOOTER_H + w = 33.87 + + # Fond blanc + add_rect(slide, 0, top, w, h, "#ffffff") + # Bordure top + add_line(slide, 0, top, w, top, "#e8e2d6", 0.03) + + # Numéro de slide + add_text_box(slide, 0.80, top + 0.10, 1.50, 0.50, + str(slide_num), self._font_body, 8, + color="#000a32", align=PP_ALIGN.LEFT) + + # Séparateur vertical + add_line(slide, 1.50, top + 0.10, 1.50, top + 0.60, "#7fa5d0", 0.03) + + # "Pernod Ricard" + add_text_box(slide, 1.70, top + 0.10, 5.00, 0.50, + "Pernod Ricard", self._font_body, 8, + color="#000a32", align=PP_ALIGN.LEFT) + + # Tagline à droite + add_text_box(slide, 15.00, top + 0.10, 18.00, 0.50, + "DATA GOVERNANCE DATA MANAGEMENT", + self._font_body, 7, + color="#48545a", align=PP_ALIGN.RIGHT) + + def _render_logo(self, slide, layout_name: str): + """Insère le logo PR top-left si le fichier assets/logo_pr_sun.png existe.""" + logo_cfg = self.theme["signature"]["logo_topbar"] + visible_on = logo_cfg.get("visible_on", []) + if layout_name not in visible_on: + return + + logo_path = logo_cfg.get("file", "assets/logo_pr_sun.png") + if not os.path.exists(logo_path): + # Logo absent → on dessine un proxy (cercle orange petit) + shape = slide.shapes.add_shape(9, # ellipse + cm(logo_cfg["position_left_cm"]), + cm(logo_cfg["position_top_cm"]), + cm(logo_cfg["height_cm"]), + cm(logo_cfg["height_cm"])) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb("#ff9166") + shape.line.fill.background() + return + + slide.shapes.add_picture( + logo_path, + cm(logo_cfg["position_left_cm"]), + cm(logo_cfg["position_top_cm"]), + height=cm(logo_cfg["height_cm"]) + ) + + def _render_accent_bar(self, slide, layout_name: str, title_h: float): + """Barre verticale rose à gauche du titre (C03).""" + sig = self.theme["signature"]["accent_bar"] + if layout_name not in sig.get("visible_on", []): + return + + bar_h = max(title_h, 0.60) + add_rect(slide, + sig["position_left_cm"], 0.45, + sig["width_cm"], bar_h, + sig["color"]) + + # ── Titre ───────────────────────────────────────────────────────────── + + def _render_title(self, slide, layout_cfg: dict, + slide_data: dict, title_h: float) -> float: + """Rend le titre et le sous-titre. Retourne le y_bottom en cm.""" + tz = layout_cfg.get("title_zone") + if not tz: + return 0.0 + + titre = slide_data.get("titre", "") + sous_titre = slide_data.get("sous_titre", "") + + left = tz.get("left_cm", 1.80) + top = tz.get("top_cm", 0.45) + width = tz.get("width_cm", 30.00) + + font_override = tz.get("font_override", {}) + size_pt = font_override.get("size_pt", + self.theme["typography"]["sizes"]["slide_title"]) + bold = font_override.get("bold", True) + color = self._r(font_override.get("color", + self.theme["colors"]["text"]["on_white"])) + + # Titre principal + h_titre = estimate_text_height(titre, size_pt, width, 1.1) + h_titre = max(h_titre, size_pt * 0.035 + 0.1) + add_text_box(slide, left, top, width, h_titre + 0.20, + titre, self._font_display, size_pt, + bold=bold, color=color) + + current_bottom = top + h_titre + 0.20 + + # Sous-titre + if sous_titre: + sub_cfg = tz.get("subtitle", {}) + sub_size = sub_cfg.get("size_pt", + self.theme["typography"]["sizes"]["slide_subtitle"]) + sub_color = self._r(sub_cfg.get("color", + self.theme["colors"]["text"]["subtitle"])) + margin = sub_cfg.get("margin_top_cm", 0.10) + add_text_box(slide, left, current_bottom + margin, + width, 0.60, + sous_titre, self._font_body, sub_size, + color=sub_color) + current_bottom += margin + 0.60 + + return current_bottom + + # ── Dispatch des zones ──────────────────────────────────────────────── + + def _zone_has_data(self, zone: dict, slide_data: dict) -> bool: + """Vérifie si une zone optionnelle a des données dans le JSON.""" + comp = zone.get("component", "") + if comp == "C07": + return any(k in slide_data for k in + ["description", "situation", "complication", "resolution", "contenu"]) + return True + + def _render_zone(self, slide, zone: dict, slide_data: dict, + left: float, top: float, width: float, height: float): + """Dispatche vers le renderer du composant.""" + comp = zone.get("component", "") + zone_type = zone.get("type", "") + + # Séparateurs (pas de composant associé) + if zone_type == "vertical_line": + add_line(slide, zone.get("x_cm", left), + zone.get("top_cm", top), + zone.get("x_cm", left), + zone.get("top_cm", top) + zone.get("height_cm", height), + self._r(zone.get("color", "#e8e2d6")), + zone.get("width_cm", 0.03)) + return + if zone_type == "horizontal_line": + y = zone.get("y_cm", top) + add_line(slide, left, y, left + width, y, + self._r(zone.get("color", "#e8e2d6")), + zone.get("width_cm", 0.03)) + return + + dispatch = { + "C06": self._render_bullet_list, + "C07": self._render_text_paragraph, + "C08": self._render_quote_block, + "C09": self._render_kpi_grid, + "C10": self._render_big_stat, + "C11": self._render_data_table, + "C12": self._render_chart_placeholder, + "C13": self._render_callout_box, + "C14": self._render_benchmark, + "C15": self._render_matrix_2x2, + "C16": self._render_pyramid, + "C17": self._render_circular_diagram, + "C18": self._render_from_to_pairs, + "C19": self._render_numbered_steps, + "C20": self._render_chevrons, + "C21": self._render_gantt, + "C22": self._render_timeline, + "C23": self._render_org_chart, + "C24": self._render_raci, + "C25": self._render_decision_tree, + "C26": self._render_recommendation_sidebar, + } + + renderer = dispatch.get(comp) + if renderer: + renderer(slide, zone, slide_data, left, top, width, height) + else: + # Composant inconnu → zone grise placeholder + self._render_placeholder(slide, left, top, width, height, comp) + + # ── Renderers des composants ────────────────────────────────────────── + + def _render_placeholder(self, slide, left, top, width, height, label="?"): + """Zone placeholder pour composants non encore implémentés.""" + add_rect(slide, left, top, width, height, "#f5f1ea") + add_text_box(slide, left + 0.5, top + height / 2 - 0.3, + width - 1, 0.6, + f"[ {label} — à implémenter ]", + self._font_body, 10, color="#9a9a9a", + align=PP_ALIGN.CENTER) + + # C06 — bullet_list ──────────────────────────────────────────────────── + + def _render_bullet_list(self, slide, zone, slide_data, + left, top, width, height): + """Bullets hiérarchisés L1/L2/L3.""" + # Cherche les bullets dans le JSON (champ direct ou dans une colonne) + zone_id = zone.get("id", "") + if "col_left" in zone_id: + col_data = slide_data.get("left", {}) + elif "col_right" in zone_id: + col_data = slide_data.get("right", {}) + else: + col_data = slide_data + + bullets = col_data.get("bullets", []) + if not bullets: + return + + txBox = slide.shapes.add_textbox( + cm(left), cm(top), cm(width), cm(height)) + tf = txBox.text_frame + tf.word_wrap = True + + sizes = {1: 11, 2: 10, 3: 9} + colors = { + 1: "#000a32", + 2: self.theme["colors"]["text"]["body"], + 3: self.theme["colors"]["text"]["body"], + } + indents = {1: 0, 2: 0.5, 3: 1.0} + markers = {1: "• ", 2: "– ", 3: "▪ "} + space_before = {1: Pt(4), 2: Pt(2), 3: Pt(1)} + + first = True + for b in bullets: + lvl = b.get("niveau", 1) + text = b.get("texte", "") + + p = tf.paragraphs[0] if first else tf.add_paragraph() + first = False + p.space_before = space_before.get(lvl, Pt(4)) + p.alignment = PP_ALIGN.LEFT + + # Indentation via l'XML (level) + pPr = p._p.get_or_add_pPr() + pPr.set("lvl", str(lvl - 1)) + + run = p.add_run() + run.text = markers[lvl] + text + run.font.name = self._font_body + run.font.size = Pt(sizes[lvl]) + run.font.bold = (lvl == 1) + run.font.color.rgb = hex_to_rgb(colors[lvl]) + + # Sous-items récursifs + for sub in b.get("sous_items", []) or []: + p2 = tf.add_paragraph() + p2.alignment = PP_ALIGN.LEFT + run2 = p2.add_run() + run2.text = " – " + sub + run2.font.name = self._font_body + run2.font.size = Pt(9) + run2.font.color.rgb = hex_to_rgb(self.theme["colors"]["text"]["body"]) + + # C07 — text_paragraph ───────────────────────────────────────────────── + + def _render_text_paragraph(self, slide, zone, slide_data, + left, top, width, height): + """Bloc de texte libre avec titre de bloc optionnel.""" + zone_id = zone.get("id", "") + + # Mapping zone_id → champ JSON + field_map = { + "bloc_situation": ("Situation", "situation"), + "bloc_complication": ("Complication", "complication"), + "bloc_resolution": ("Résolution", "resolution"), + "col_left": (None, "left"), + "col_right": (None, "right"), + "description_bloc": (None, "description"), + "contact": (None, "contacts"), + "next_steps": (None, "message"), + } + + titre_bloc, field = field_map.get(zone_id, (None, "contenu")) + titre_couleur = self._r(zone.get("titre_couleur", + self.theme["colors"]["primary"]["dark_blue"])) + font_override = zone.get("font_override", {}) + + cur_top = top + + # Titre de bloc + if titre_bloc: + add_text_box(slide, left, cur_top, width, 0.50, + titre_bloc, self._font_body, 13, + bold=True, color=titre_couleur) + cur_top += 0.55 + + # Contenu + raw = slide_data.get(field, "") + if isinstance(raw, dict): + titre_col = raw.get("titre", "") + contenu = raw.get("contenu", "") + if titre_col: + add_text_box(slide, left, cur_top, width, 0.45, + titre_col, self._font_body, 13, + bold=True, color=titre_couleur) + cur_top += 0.50 + raw = contenu + + if raw: + color = font_override.get("color", self.theme["colors"]["text"]["body"]) + size_pt = font_override.get("size_pt", 11) + add_text_box(slide, left, cur_top, width, + height - (cur_top - top), + str(raw), self._font_body, size_pt, + color=color) + + # C08 — quote_block ──────────────────────────────────────────────────── + + def _render_quote_block(self, slide, zone, slide_data, + left, top, width, height): + """Citation / key message avec guillemets Cormorant.""" + citation = slide_data.get("message") or slide_data.get("citation", "") + auteur = slide_data.get("auteur", "") + fonction = slide_data.get("fonction", "") + + # Guillemet décoratif + add_text_box(slide, left, top + 0.3, 2.0, 1.5, + "\u201C", self._font_display, 72, + color=self.theme["colors"]["primary"]["bright_blue"]) + + # Message + add_text_box(slide, left + 1.5, top + 1.2, + width - 1.5, height - 2.0, + citation, self._font_display, 22, + color=self.theme["colors"]["primary"]["dark_blue"]) + + # Attribution + if auteur or fonction: + attr = f"{auteur} {fonction}".strip() + add_text_box(slide, left + 1.5, + top + height - 1.2, + width - 1.5, 0.60, + attr, self._font_body, 10, + color=self.theme["colors"]["text"]["body"]) + + # C09 — kpi_grid ─────────────────────────────────────────────────────── + + def _render_kpi_grid(self, slide, zone, slide_data, + left, top, width, height): + """Grille de cartes KPI adaptative.""" + items = slide_data.get("items", []) + if not items: + return + + n = len(items) + grid = {2: (2, 1), 3: (3, 1), 4: (2, 2), 5: (3, 2), 6: (3, 2)} + cols, rows = grid.get(n, (3, 2)) + gap = 0.40 + + card_w = (width - (cols - 1) * gap) / cols + card_h = (height - (rows - 1) * gap) / rows + + for i, item in enumerate(items): + col = i % cols + row = i // cols + x = left + col * (card_w + gap) + y = top + row * (card_h + gap) + + color = item.get("couleur") or self._cycle_color() + color = self._r(color) + header_h = 0.55 + + # Header coloré + add_rect(slide, x, y, card_w, header_h, color) + add_text_box(slide, x + 0.2, y + 0.10, + card_w - 0.4, header_h - 0.10, + item.get("titre", ""), + self._font_body, 9, + bold=True, color="#ffffff") + + # Body beige + add_rect(slide, x, y + header_h, card_w, + card_h - header_h, + self.theme["colors"]["backgrounds"]["content_area"]) + + # Valeur en gros + val_h = card_h - header_h - 1.0 + add_text_box(slide, x + 0.2, y + header_h + 0.3, + card_w - 0.4, val_h, + item.get("valeur", ""), + self._font_display, 32, + bold=True, + color=self.theme["colors"]["primary"]["rose"], + align=PP_ALIGN.CENTER) + + # Sous-titre + if item.get("sous_titre"): + add_text_box(slide, x + 0.2, + y + card_h - 0.8, + card_w - 0.4, 0.70, + item["sous_titre"], + self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # C10 — big_stat_display ─────────────────────────────────────────────── + + def _render_big_stat(self, slide, zone, slide_data, + left, top, width, height): + """Chiffre unique centré en très grand format.""" + valeur = slide_data.get("valeur", "") + label = slide_data.get("label", "") + source = slide_data.get("source", "") + + center_top = top + (height - 4.0) / 2 + + # Valeur + add_text_box(slide, left, center_top, width, 2.80, + valeur, self._font_display, 72, + bold=True, + color=self.theme["colors"]["primary"]["rose"], + align=PP_ALIGN.CENTER) + + if label: + add_text_box(slide, left, center_top + 2.90, width, 0.70, + label, self._font_body, 11, + color=self.theme["colors"]["text"]["body"], + align=PP_ALIGN.CENTER) + if source: + add_text_box(slide, left, center_top + 3.70, width, 0.50, + f"Source : {source}", self._font_body, 9, + color=self.theme["colors"]["text"]["caption"], + align=PP_ALIGN.CENTER) + + # C11 — data_table ───────────────────────────────────────────────────── + + def _render_data_table(self, slide, zone, slide_data, + left, top, width, height): + """Tableau structuré avec header bleu foncé et lignes alternées.""" + headers = slide_data.get("headers", []) + rows = slide_data.get("rows", []) + if not headers: + return + + highlight_col = slide_data.get("highlight_col") + col_widths_pct = slide_data.get("col_widths") + + n_cols = len(headers) + header_h = 0.65 + available_h = height - header_h + row_h = min(available_h / max(len(rows), 1), 0.80) + + # Largeurs de colonnes + if col_widths_pct: + col_widths = [w * width for w in col_widths_pct] + else: + col_widths = [width / n_cols] * n_cols + + # Header + x = left + for j, h in enumerate(headers): + add_rect(slide, x, top, col_widths[j], header_h, + self.theme["colors"]["primary"]["dark_blue"]) + add_text_box(slide, x + 0.15, top + 0.10, + col_widths[j] - 0.3, header_h - 0.15, + str(h), self._font_body, 9, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + x += col_widths[j] + + # Lignes + odd_bg = "#ffffff" + even_bg = self.theme["colors"]["backgrounds"]["content_area"] + highlight_bg = self.theme["colors"]["backgrounds"]["highlight_box"] + + for i, row in enumerate(rows): + y = top + header_h + i * row_h + x = left + for j, cell in enumerate(row): + bg = highlight_bg if j == highlight_col else ( + odd_bg if i % 2 == 0 else even_bg) + add_rect(slide, x, y, col_widths[j], row_h, bg) + add_text_box(slide, x + 0.15, y + 0.08, + col_widths[j] - 0.3, row_h - 0.10, + str(cell), self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + x += col_widths[j] + # Ligne séparatrice + add_line(slide, left, y + row_h, left + width, y + row_h, + "#e8e2d6", 0.02) + + # C12 — chart_placeholder ────────────────────────────────────────────── + + def _render_chart_placeholder(self, slide, zone, slide_data, + left, top, width, height): + """ + Graphique simplifié (bar chart) généré avec python-pptx Chart. + Pour un rendu avancé, remplacer par openpyxl + pptx chart data. + """ + from pptx.chart.data import ChartData + from pptx.enum.chart import XL_CHART_TYPE + + data_items = slide_data.get("data", []) + chart_type = slide_data.get("chart_type", "bar") + if not data_items: + self._render_placeholder(slide, left, top, width, height, "C12 chart") + return + + chart_data = ChartData() + chart_data.categories = [str(d.get("label", f"Item {i+1}")) + for i, d in enumerate(data_items)] + chart_data.add_series("", [float(d.get("valeur", 0)) + for d in data_items]) + + xl_type = { + "bar": XL_CHART_TYPE.BAR_CLUSTERED, + "line": XL_CHART_TYPE.LINE, + "pie": XL_CHART_TYPE.PIE, + "donut": XL_CHART_TYPE.DOUGHNUT, + }.get(chart_type, XL_CHART_TYPE.BAR_CLUSTERED) + + chart = slide.shapes.add_chart( + xl_type, + cm(left), cm(top), cm(width), cm(height), + chart_data + ).chart + + # Supprimer le titre du chart (on a déjà le titre du slide) + chart.has_title = False + chart.has_legend = False + + # Couleur des barres + series = chart.series[0] + fill = series.format.fill + fill.solid() + fill.fore_color.rgb = hex_to_rgb( + self.theme["colors"]["primary"]["bright_blue"]) + + # C13 — callout_box ──────────────────────────────────────────────────── + + def _render_callout_box(self, slide, zone, slide_data, + left, top, width, height): + """Encadré d'insight jaune.""" + insight = slide_data.get("insight", "") + titre = slide_data.get("titre_insight", "") + + # Fond + add_rect(slide, left, top, width, height, + self.theme["colors"]["backgrounds"]["highlight_box"], + self.theme["colors"]["secondary"]["maize_yellow"], 0.05) + + cur_top = top + 0.30 + if titre: + add_text_box(slide, left + 0.30, cur_top, + width - 0.60, 0.50, + titre, self._font_body, 12, + bold=True, + color=self.theme["colors"]["primary"]["rose"]) + cur_top += 0.55 + + if insight: + add_text_box(slide, left + 0.30, cur_top, + width - 0.60, height - (cur_top - top) - 0.30, + insight, self._font_body, 10, + color=self.theme["colors"]["text"]["body"]) + + # C14 — benchmark_bar ────────────────────────────────────────────────── + + def _render_benchmark(self, slide, zone, slide_data, + left, top, width, height): + """Barres horizontales de benchmark.""" + criteria = slide_data.get("criteria", []) + actors = slide_data.get("actors", []) + scores = slide_data.get("scores", []) + if not criteria or not actors: + return + + colors_actors = slide_data.get("couleurs_acteurs") or [ + self.theme["colors"]["primary"]["bright_blue"], + self.theme["colors"]["primary"]["rose"], + self.theme["colors"]["secondary"]["barley_green"], + self.theme["colors"]["secondary"]["maize_yellow"], + ] + + n_crit = len(criteria) + n_act = len(actors) + label_w = 5.00 + bar_area_w = width - label_w + row_h = height / n_crit + bar_h = 0.30 + bar_gap = 0.10 + + # Légende acteurs (en haut) + for j, actor in enumerate(actors): + add_rect(slide, left + label_w + j * 2.0, top - 0.50, + 0.25, 0.25, colors_actors[j % len(colors_actors)]) + add_text_box(slide, left + label_w + j * 2.0 + 0.30, + top - 0.55, 1.5, 0.35, + actor, self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + for i, crit in enumerate(criteria): + y = top + i * row_h + + # Label critère + add_text_box(slide, left, y + row_h / 2 - 0.20, + label_w - 0.30, 0.40, + crit, self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # Barres par acteur + for j in range(n_act): + score = 0 + if i < len(scores) and j < len(scores[i]): + score = float(scores[i][j]) + bar_w = (score / 100) * bar_area_w + + bar_y = y + (row_h - n_act * (bar_h + bar_gap)) / 2 + j * (bar_h + bar_gap) + add_rect(slide, left + label_w, bar_y, + max(bar_w, 0.05), bar_h, + colors_actors[j % len(colors_actors)]) + + # C15 — matrix_bubble ───────────────────────────────────────────────── + + def _render_matrix_2x2(self, slide, zone, slide_data, + left, top, width, height): + """Matrice 2×2 avec bulles positionnées.""" + axis_x = slide_data.get("axis_x", {}) + axis_y = slide_data.get("axis_y", {}) + items = slide_data.get("items", []) + + ax_label = str(axis_x.get("label", "")) + ay_label = str(axis_y.get("label", "")) + + # Marges pour les labels d'axes + margin_left = 1.50 + margin_bottom = 0.80 + plot_w = width - margin_left + plot_h = height - margin_bottom + + # Axes + add_line(slide, left + margin_left, top, + left + margin_left, top + plot_h, + "#000a32", 0.05) + add_line(slide, left + margin_left, top + plot_h, + left + width, top + plot_h, + "#000a32", 0.05) + + # Lignes de quadrant + mid_x = left + margin_left + plot_w / 2 + mid_y = top + plot_h / 2 + add_line(slide, mid_x, top, mid_x, top + plot_h, "#48545a", 0.02) + add_line(slide, left + margin_left, mid_y, + left + width, mid_y, "#48545a", 0.02) + + # Labels axes + add_text_box(slide, left + margin_left + plot_w / 2 - 2, + top + plot_h + 0.10, + 4, 0.40, ax_label, + self._font_body, 9, color="#48545a", + align=PP_ALIGN.CENTER) + add_text_box(slide, left, top + plot_h / 2 - 0.30, + margin_left - 0.10, 0.60, ay_label, + self._font_body, 9, color="#48545a", + align=PP_ALIGN.RIGHT) + + # Labels extremes + add_text_box(slide, left + margin_left - 0.5, top + plot_h - 0.20, + 0.8, 0.30, "Low", self._font_body, 8, color="#48545a") + add_text_box(slide, left + margin_left - 0.5, top, + 0.8, 0.30, "High", self._font_body, 8, color="#48545a") + add_text_box(slide, left + margin_left, top + plot_h, + 0.8, 0.30, "Low", self._font_body, 8, color="#48545a") + add_text_box(slide, left + width - 1.0, top + plot_h, + 1.0, 0.30, "High", self._font_body, 8, color="#48545a", + align=PP_ALIGN.RIGHT) + + colors = self.theme["colors"]["cycle"] + for i, item in enumerate(items): + x_pct = item.get("x", 50) / 100 + y_pct = 1 - item.get("y", 50) / 100 # inverser y (0 = bas) + size_factor = item.get("taille", 2) + diameter = 0.30 + (size_factor - 1) * 0.15 + color = self._r(item.get("couleur") or colors[i % len(colors)]) + + bx = left + margin_left + x_pct * plot_w - diameter / 2 + by = top + y_pct * plot_h - diameter / 2 + + shape = slide.shapes.add_shape(9, # ellipse + cm(bx), cm(by), cm(diameter), cm(diameter)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(color) + shape.fill.fore_color.theme_color + shape.line.fill.background() + # Opacité via XML + spPr = shape._element.spPr + solidFill = spPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}solidFill') + if solidFill is not None: + srgbClr = solidFill.find('{http://schemas.openxmlformats.org/drawingml/2006/main}srgbClr') + if srgbClr is not None: + alpha = etree.SubElement(srgbClr, + '{http://schemas.openxmlformats.org/drawingml/2006/main}alpha') + alpha.set('val', '75000') # 75% opacité + + # Label + add_text_box(slide, bx - 0.5, by + diameter + 0.05, + diameter + 1.0, 0.35, + item.get("label", ""), + self._font_body, 8, + color=self.theme["colors"]["primary"]["dark_blue"], + align=PP_ALIGN.CENTER) + + # C16 — pyramid_level ───────────────────────────────────────────────── + + def _render_pyramid(self, slide, zone, slide_data, + left, top, width, height): + """Pyramide hiérarchique.""" + levels = slide_data.get("levels", []) + if not levels: + return + + n = len(levels) + colors_default = [ + "#7fa5d0", "#000a32", "#bad9ff", "#ffcf0f", "#d9d9c4" + ] + level_h = height / n + center_x = left + width / 2 + max_w = width * 0.55 + callout_right_x = left + width * 0.70 + + for i, level in enumerate(levels): + rank = i + 1 + frac = rank / n + lvl_w = max_w * frac + lvl_left = center_x - lvl_w / 2 + lvl_top = top + i * level_h + color = self._r(level.get("couleur") or colors_default[i % len(colors_default)]) + + add_rect(slide, lvl_left, lvl_top, lvl_w, level_h - 0.05, color) + add_text_box(slide, lvl_left, lvl_top + level_h / 2 - 0.20, + lvl_w, 0.40, + level.get("label", ""), + self._font_body, 9, + bold=True, + color="#ffffff" if i in [1] else "#000a32", + align=PP_ALIGN.CENTER) + + # Callout latéral + if level.get("description"): + side = "right" if i % 2 == 0 else "left" + if side == "right": + add_line(slide, lvl_left + lvl_w, lvl_top + level_h / 2, + callout_right_x, lvl_top + level_h / 2, + "#48545a", 0.02) + add_text_box(slide, callout_right_x + 0.10, + lvl_top + level_h / 2 - 0.20, + left + width - callout_right_x - 0.20, + 0.60, level["description"], + self._font_body, 8, + color=self.theme["colors"]["text"]["body"]) + else: + callout_left_x = left + add_line(slide, lvl_left, lvl_top + level_h / 2, + callout_left_x + width * 0.25, + lvl_top + level_h / 2, "#48545a", 0.02) + add_text_box(slide, callout_left_x, + lvl_top + level_h / 2 - 0.20, + width * 0.24, 0.60, + level["description"], + self._font_body, 8, + color=self.theme["colors"]["text"]["body"], + align=PP_ALIGN.RIGHT) + + # C17 — circular_segment ─────────────────────────────────────────────── + + def _render_circular_diagram(self, slide, zone, slide_data, + left, top, width, height): + """Diagramme circulaire (approximation par secteurs via rectangles colorés).""" + segments = slide_data.get("segments", []) + if not segments: + return + + colors_default = self.theme["colors"]["cycle"] + n = len(segments) + + # Cercle central approximé (zones colorées en 2×N) + # Note : python-pptx ne supporte pas les pie charts custom facilement. + # On utilise des ellipses + médaillon central. + cx = left + width * 0.35 + cy = top + height / 2 + r = min(height * 0.38, width * 0.25) + + # Secteurs simulés par des rectangles colorés en arc + # (approximation visuelle — pour un vrai pie, utiliser chart_data) + angle_step = 360 / n + for i, seg in enumerate(segments): + color = self._r(seg.get("couleur") or colors_default[i % len(colors_default)]) + # Ellipse approximant un secteur + angle_rad = math.radians(i * angle_step) + sx = cx + r * 0.5 * math.cos(angle_rad) + sy = cy + r * 0.5 * math.sin(angle_rad) + shape = slide.shapes.add_shape(9, + cm(sx - r * 0.45), cm(sy - r * 0.45), + cm(r * 0.90), cm(r * 0.90)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(color) + shape.line.fill.background() + + # Médaillon central blanc + shape = slide.shapes.add_shape(9, + cm(cx - r * 0.35), cm(cy - r * 0.35), + cm(r * 0.70), cm(r * 0.70)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb("#ffffff") + shape.line.fill.background() + + # Légende à droite + leg_left = left + width * 0.55 + leg_top = top + (height - n * 1.4) / 2 + + for i, seg in enumerate(segments): + color = self._r(seg.get("couleur") or colors_default[i % len(colors_default)]) + y = leg_top + i * 1.40 + + # Pastille + shape = slide.shapes.add_shape(9, + cm(leg_left), cm(y + 0.05), + cm(0.35), cm(0.35)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(color) + shape.line.fill.background() + + # Num + label + add_text_box(slide, leg_left + 0.50, y, + width - (leg_left - left) - 0.60, 0.40, + f"0{i+1} {seg.get('label', '')}", + self._font_body, 10, bold=True, + color=self.theme["colors"]["primary"]["dark_blue"]) + if seg.get("description"): + add_text_box(slide, leg_left + 0.50, y + 0.42, + width - (leg_left - left) - 0.60, 0.70, + seg["description"], + self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # C18 — from_to_pair ─────────────────────────────────────────────────── + + def _render_from_to_pairs(self, slide, zone, slide_data, + left, top, width, height): + """Paires FROM → TO.""" + pairs = slide_data.get("pairs", []) + if not pairs: + return + + # En-tête FROM / TO + mid_x = left + width * 0.42 + add_text_box(slide, left, top, width * 0.40, 0.50, + "FROM", self._font_body, 11, + bold=True, + color=self.theme["semantic"]["arrow_color"]) + add_text_box(slide, mid_x + 0.80, top, width * 0.40, 0.50, + "TO", self._font_body, 11, + bold=True, + color=self.theme["semantic"]["arrow_color"]) + + row_h = (height - 0.60) / max(len(pairs), 1) + for i, pair in enumerate(pairs): + y = top + 0.60 + i * row_h + # FROM (atténué) + add_text_box(slide, left, y + 0.08, + width * 0.38, row_h - 0.15, + pair.get("from", ""), + self._font_body, 11, + color=self.theme["semantic"]["from_color"]) + + # Flèche + add_text_box(slide, mid_x - 0.20, y + 0.05, 0.60, 0.40, + "›", self._font_body, 18, bold=True, + color=self.theme["semantic"]["arrow_color"], + align=PP_ALIGN.CENTER) + + # TO (affirmé) + add_text_box(slide, mid_x + 0.50, y + 0.08, + width - mid_x - 0.50, row_h - 0.15, + pair.get("to", ""), + self._font_body, 11, + bold=True, + color=self.theme["semantic"]["to_color"]) + + # Séparateur + if i < len(pairs) - 1: + add_line(slide, left, y + row_h, + left + width, y + row_h, + "#e8e2d6", 0.02) + + # C19 — step_item ────────────────────────────────────────────────────── + + def _render_numbered_steps(self, slide, zone, slide_data, + left, top, width, height): + """Étapes numérotées verticalement.""" + steps = slide_data.get("steps", []) + if not steps: + return + + row_h = height / max(len(steps), 1) + badge_size = 0.70 + + for i, step in enumerate(steps): + y = top + i * row_h + + # Badge carré + add_rect(slide, left, y + (row_h - badge_size) / 2, + badge_size, badge_size, + self.theme["colors"]["primary"]["dark_blue"]) + add_text_box(slide, left, y + (row_h - badge_size) / 2, + badge_size, badge_size, + str(step.get("numero", i + 1)), + self._font_body, 11, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + + # Titre + add_text_box(slide, left + badge_size + 0.25, + y + (row_h - badge_size) / 2, + width * 0.35, badge_size, + step.get("titre", ""), + self._font_body, 13, + bold=True, + color=self.theme["colors"]["primary"]["dark_blue"]) + + # Description + if step.get("description"): + add_text_box(slide, left + badge_size + 0.25 + width * 0.36, + y + (row_h - badge_size) / 2, + width - badge_size - 0.25 - width * 0.36, + badge_size, + step["description"], + self._font_body, 10, + color=self.theme["colors"]["text"]["body"]) + + # Séparateur + if i < len(steps) - 1: + add_line(slide, left, y + row_h, + left + width, y + row_h, + "#e8e2d6", 0.02) + + # C20 — chevron_step ─────────────────────────────────────────────────── + + def _render_chevrons(self, slide, zone, slide_data, + left, top, width, height): + """Chevrons horizontaux de process.""" + phases = slide_data.get("phases", []) + if not phases: + return + + n = len(phases) + tip = 0.40 # largeur de la pointe + chev_h = 1.00 + total_w = width - 0.50 + chev_w = total_w / n + bullets_top = top + chev_h + 0.30 + + for i, phase in enumerate(phases): + x = left + i * chev_w + is_active = phase.get("actif", False) + is_last = (i == n - 1) + + fill = (self.theme["colors"]["primary"]["dark_blue"] + if is_active else + self.theme["colors"]["primary"]["hague_grey"]) + + # Rectangle du chevron + add_rect(slide, x, top, chev_w - 0.10, chev_h, fill) + # Texte + add_text_box(slide, x + 0.20, top + 0.20, + chev_w - 0.60, 0.60, + phase.get("label", ""), + self._font_display, 13, + bold=True, + color=self.theme["colors"]["primary"]["rose"] + if is_active else "#ffffff", + align=PP_ALIGN.CENTER) + + # Durée sous le chevron + if phase.get("duree"): + add_text_box(slide, x, top + chev_h + 0.05, + chev_w, 0.30, + phase["duree"], + self._font_body, 8, + color=self.theme["colors"]["text"]["body"], + align=PP_ALIGN.CENTER) + + # Bullets sous la phase + if phase.get("bullets"): + for j, bullet in enumerate(phase["bullets"]): + add_text_box(slide, x + 0.15, + bullets_top + j * 0.55, + chev_w - 0.30, 0.50, + "• " + bullet, + self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # C21 — gantt_bar ────────────────────────────────────────────────────── + + def _render_gantt(self, slide, zone, slide_data, + left, top, width, height): + """Gantt simplifié.""" + period = slide_data.get("period", {}) + workstreams = slide_data.get("workstreams", []) + if not workstreams: + return + + label_w = zone.get("label_col_width_cm", 5.50) + header_h = zone.get("header_height_cm", 0.60) + stream_h = zone.get("workstream_height_cm", 2.80) + timeline_w = width - label_w + + # Parse période + def parse_ym(s): + parts = str(s).split("-") + return int(parts[0]) * 12 + int(parts[1]) if len(parts) == 2 else 0 + + p_start = parse_ym(period.get("start", "2026-01")) + p_end = parse_ym(period.get("end", "2026-12")) + total_months = max(p_end - p_start + 1, 1) + + # Header mois + import calendar + months_short = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + add_rect(slide, left + label_w, top, timeline_w, header_h, + self.theme["colors"]["backgrounds"]["content_area"]) + + for m in range(total_months): + mx = left + label_w + (m / total_months) * timeline_w + mw = timeline_w / total_months + ym = p_start + m + month_name = months_short[(ym - 1) % 12] + add_text_box(slide, mx, top + 0.08, mw, 0.40, + month_name, self._font_body, 7, + color="#48545a", align=PP_ALIGN.CENTER) + + colors = self.theme["colors"]["cycle"] + + for i, ws in enumerate(workstreams): + y = top + header_h + i * stream_h + color = colors[i % len(colors)] + + # Label workstream (optionnel) + if ws.get("label"): + add_rect(slide, left, y, label_w - 0.20, stream_h - 0.10, + self.theme["colors"]["backgrounds"]["content_area"]) + add_text_box(slide, left + 0.15, y + stream_h / 2 - 0.20, + label_w - 0.40, 0.40, + ws["label"], self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + else: + add_rect(slide, left, y, label_w - 0.20, stream_h - 0.10, + "#e8e2d6") + + # Barres de tâches + for task in ws.get("tasks", []): + t_start = parse_ym(task.get("start", period.get("start"))) + t_end = parse_ym(task.get("end", period.get("end"))) + row = task.get("row", 1) + + offset_x = ((t_start - p_start) / total_months) * timeline_w + bar_w = max(((t_end - t_start + 1) / total_months) * timeline_w, 0.30) + bar_y = y + (row - 1) * (stream_h / 2) + 0.25 + bar_h = stream_h / 2 - 0.35 + + tc = self._r(task.get("couleur") or color) + add_rect(slide, left + label_w + offset_x, bar_y, + bar_w, bar_h, tc) + + # C22 — timeline_milestone ───────────────────────────────────────────── + + def _render_timeline(self, slide, zone, slide_data, + left, top, width, height): + """Timeline horizontale (yearly ou phases).""" + milestones = slide_data.get("milestones", []) + if not milestones: + # phases_timeline variant + self._render_phases_timeline(slide, zone, slide_data, left, top, width, height) + return + + n = len(milestones) + axis_y = top + height / 2 + spacing = width / (n + 1) + + # Axe + add_line(slide, left, axis_y, left + width, axis_y, "#48545a", 0.04) + # Flèche → + add_text_box(slide, left + width - 0.30, axis_y - 0.20, + 0.40, 0.40, "→", self._font_body, 10, color="#48545a") + + colors = self.theme["colors"] + for i, m in enumerate(milestones): + mx = left + (i + 1) * spacing + is_active = m.get("actif", False) + circle_color = (colors["primary"]["rose"] if is_active + else colors["primary"]["dark_blue"]) + year_color = (colors["primary"]["rose"] if is_active + else colors["primary"]["bright_blue"]) + + # Cercle sur l'axe + r = 0.18 + shape = slide.shapes.add_shape(9, + cm(mx - r), cm(axis_y - r), cm(r * 2), cm(r * 2)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(circle_color) + shape.line.fill.background() + + # Année au-dessus + add_text_box(slide, mx - 1.0, axis_y - 1.20, + 2.0, 0.50, str(m.get("annee", "")), + self._font_display, 13, + bold=True, color=year_color, + align=PP_ALIGN.CENTER) + + # Label + add_text_box(slide, mx - 1.5, axis_y + 0.30, + 3.0, 0.40, m.get("label", ""), + self._font_body, 9, + bold=True if is_active else False, + color=colors["primary"]["dark_blue"], + align=PP_ALIGN.CENTER) + + # Description + if m.get("description"): + add_text_box(slide, mx - 1.5, axis_y + 0.75, + 3.0, 0.70, m["description"], + self._font_body, 8, + color=colors["text"]["body"], + align=PP_ALIGN.CENTER) + + def _render_phases_timeline(self, slide, zone, slide_data, + left, top, width, height): + """Phases horizontales contiguës (L24).""" + phases = slide_data.get("phases", []) + if not phases: + return + + n = len(phases) + phase_h = 0.65 + period_h = 0.40 + colors = self.theme["colors"]["cycle"] + + # Largeur proportionnelle ou égale + phase_w = width / n + + for i, phase in enumerate(phases): + x = left + i * phase_w + color = colors[i % len(colors)] + + add_rect(slide, x, top, phase_w - 0.10, phase_h, color) + add_text_box(slide, x + 0.10, top + 0.10, + phase_w - 0.20, phase_h - 0.15, + phase.get("label", ""), + self._font_body, 8, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + + if phase.get("periode"): + add_text_box(slide, x, top + phase_h + 0.05, + phase_w, period_h, + phase["periode"], + self._font_body, 7, + color=self._r(color), + align=PP_ALIGN.CENTER) + + items = phase.get("items", []) + for j, item in enumerate(items): + add_text_box(slide, x + 0.10, + top + phase_h + period_h + 0.20 + j * 0.55, + phase_w - 0.20, 0.50, + "• " + item, + self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # C23 — org_node ─────────────────────────────────────────────────────── + + def _render_org_chart(self, slide, zone, slide_data, + left, top, width, height): + """Organigramme hiérarchique top-down.""" + root = slide_data.get("root", {}) + if not root: + return + + colors_by_level = [ + self.theme["colors"]["primary"]["dark_blue"], + self.theme["colors"]["secondary"]["barley_green"], + self.theme["colors"]["secondary"]["cool_blue"], + self.theme["colors"]["primary"]["bright_warm"], + ] + text_by_level = ["#ffffff", "#ffffff", "#000a32", "#000a32"] + + node_h = 0.65 + level_gap = 1.20 + + def draw_tree(node, level, x_center, y): + color = colors_by_level[min(level, len(colors_by_level) - 1)] + txt_color = text_by_level[min(level, len(text_by_level) - 1)] + node_w = max(3.0 - level * 0.3, 2.0) + + nx = x_center - node_w / 2 + add_rect(slide, nx, y, node_w, node_h, color) + add_text_box(slide, nx + 0.10, y + 0.12, + node_w - 0.20, node_h - 0.15, + node.get("label", ""), + self._font_body, 8, + bold=True, color=txt_color, + align=PP_ALIGN.CENTER) + + children = node.get("children", []) + if not children: + return + + nc = len(children) + child_span = min(width / max(nc, 1), 6.0) + children_total_w = child_span * nc + child_start_x = x_center - children_total_w / 2 + child_span / 2 + + child_y = y + node_h + level_gap + + # Ligne verticale descendante + add_line(slide, x_center, y + node_h, + x_center, y + node_h + level_gap / 2, + "#48545a", 0.03) + + # Ligne horizontale + add_line(slide, + child_start_x, y + node_h + level_gap / 2, + child_start_x + children_total_w - child_span, + y + node_h + level_gap / 2, + "#48545a", 0.03) + + for i, child in enumerate(children): + cx = child_start_x + i * child_span + add_line(slide, cx, y + node_h + level_gap / 2, + cx, child_y, "#48545a", 0.03) + draw_tree(child, level + 1, cx, child_y) + + draw_tree(root, 0, left + width / 2, top) + + # C24 — raci_cell ────────────────────────────────────────────────────── + + def _render_raci(self, slide, zone, slide_data, + left, top, width, height): + """Matrice RACI.""" + roles = slide_data.get("roles", []) + tasks = slide_data.get("tasks", []) + if not roles or not tasks: + return + + task_col_w = zone.get("task_col_width_cm", 8.00) + header_h = 0.65 + role_col_w = (width - task_col_w) / max(len(roles), 1) + row_h = min((height - header_h) / max(len(tasks), 1), 0.80) + + raci_colors = { + "R": self.theme["semantic"]["responsible"], + "A": self.theme["semantic"]["accountable"], + "C": self.theme["semantic"]["consulted"], + "I": self.theme["semantic"]["informed"], + } + + # Header + add_rect(slide, left, top, task_col_w, header_h, + self.theme["colors"]["backgrounds"]["content_area"]) + for j, role in enumerate(roles): + x = left + task_col_w + j * role_col_w + add_rect(slide, x, top, role_col_w, header_h, + self.theme["colors"]["primary"]["dark_blue"]) + add_text_box(slide, x, top + 0.10, role_col_w, 0.45, + role, self._font_body, 9, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + + for i, task in enumerate(tasks): + y = top + header_h + i * row_h + bg = ("#ffffff" if i % 2 == 0 + else self.theme["colors"]["backgrounds"]["content_area"]) + + add_rect(slide, left, y, task_col_w, row_h, bg) + add_text_box(slide, left + 0.20, y + 0.12, + task_col_w - 0.30, row_h - 0.15, + task.get("label", ""), + self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + raci_vals = task.get("raci", []) + for j in range(len(roles)): + x = left + task_col_w + j * role_col_w + add_rect(slide, x, y, role_col_w, row_h, bg) + + val = raci_vals[j] if j < len(raci_vals) else "" + if val in raci_colors: + r_d = 0.35 + r_x = x + role_col_w / 2 - r_d / 2 + r_y = y + row_h / 2 - r_d / 2 + opacity = 1.0 if val in ("R", "A", "C") else 0.45 + shape = slide.shapes.add_shape(9, + cm(r_x), cm(r_y), cm(r_d), cm(r_d)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(raci_colors[val]) + shape.line.fill.background() + + add_text_box(slide, r_x, r_y + 0.04, + r_d, r_d - 0.08, + val, self._font_body, 8, + bold=True, + color="#ffffff" if val in ("R", "A") else "#000a32", + align=PP_ALIGN.CENTER) + + add_line(slide, left, y + row_h, left + width, y + row_h, "#e8e2d6", 0.02) + + # C25 — decision_node ────────────────────────────────────────────────── + + def _render_decision_tree(self, slide, zone, slide_data, + left, top, width, height): + """Arbre de décision YES/NO.""" + question = slide_data.get("question", "") + branches = slide_data.get("branches", {}) + + # Question centrale + q_w, q_h = 7.0, 2.80 + q_x = left + 0.50 + q_y = top + height / 2 - q_h / 2 + + add_rect(slide, q_x, q_y, q_w, q_h, + self.theme["colors"]["backgrounds"]["content_area"], + "#48545a", 0.03) + add_text_box(slide, q_x + 0.30, q_y + 0.30, + q_w - 0.60, q_h - 0.60, + question, self._font_body, 10, + color=self.theme["colors"]["primary"]["dark_blue"]) + + branch_configs = [ + ("yes", "YES", top + height * 0.20), + ("no", "NO", top + height * 0.65), + ] + colors_branch = { + "yes": self.theme["colors"]["primary"]["bright_warm"], + "no": self.theme["colors"]["primary"]["rose"], + } + + for key, label, branch_y in branch_configs: + branch = branches.get(key, {}) + if not branch: + continue + + # Connecteur + label YES/NO + add_line(slide, q_x + q_w, q_y + q_h / 2, + left + q_w + 2.0, branch_y + 1.0, + "#48545a", 0.03) + add_text_box(slide, q_x + q_w + 0.20, + (q_y + q_h / 2 + branch_y + 1.0) / 2 - 0.15, + 0.80, 0.30, label, + self._font_body, 8, bold=True, + color="#48545a") + + # Nœud branche + b_x = left + q_w + 2.0 + b_w, b_h = 6.0, 2.20 + branch_color = colors_branch.get(key, "#d9d9c4") + add_rect(slide, b_x, branch_y, b_w, b_h, + branch_color, "#48545a", 0.03) + add_text_box(slide, b_x + 0.25, branch_y + 0.25, + b_w - 0.50, b_h - 0.50, + branch.get("label", ""), + self._font_body, 10, + color=self.theme["colors"]["primary"]["dark_blue"]) + + # Options terminales + options = branch.get("options", []) + opt_x = b_x + b_w + 0.80 + opt_w = left + width - opt_x - 0.20 + for k, opt in enumerate(options[:2]): + opt_y = branch_y + k * 1.20 + add_line(slide, b_x + b_w, branch_y + b_h / 2, + opt_x, opt_y + 0.40, "#48545a", 0.02) + add_rect(slide, opt_x, opt_y, opt_w, 1.0, + self.theme["colors"]["backgrounds"]["content_area"], + "#e8e2d6", 0.02) + add_text_box(slide, opt_x + 0.20, opt_y + 0.15, + opt_w - 0.40, 0.70, + opt, self._font_body, 9, + color=self.theme["colors"]["primary"]["dark_blue"]) + + # C26 — recommendation_sidebar ───────────────────────────────────────── + + def _render_recommendation_sidebar(self, slide, zone, slide_data, + left, top, width, height): + """Sidebar jaune + corps de la recommandation.""" + numero = slide_data.get("numero", 1) + titre = slide_data.get("titre", "") + resume = slide_data.get("resume", "") + cta = slide_data.get("cta", "") + headline = slide_data.get("headline", "") + bullets = slide_data.get("bullets", []) + + # Sidebar fond jaune + sidebar_w = width # width = 8.0 cm (défini dans layouts.yaml) + add_rect(slide, left, top, sidebar_w, height, + self.theme["colors"]["backgrounds"]["highlight_box"]) + + # Cercle numéro + r = 0.55 + cx = left + sidebar_w / 2 + shape = slide.shapes.add_shape(9, + cm(cx - r), cm(top + 1.0), cm(r * 2), cm(r * 2)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb( + self.theme["colors"]["primary"]["dark_blue"]) + shape.line.fill.background() + add_text_box(slide, cx - r, top + 1.0, r * 2, r * 2, + str(numero), self._font_display, 18, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + + # Titre sidebar + add_text_box(slide, left + 0.30, top + 2.30, + sidebar_w - 0.60, 1.50, + titre, self._font_display, 16, + bold=True, + color=self.theme["colors"]["primary"]["dark_blue"], + align=PP_ALIGN.CENTER) + + # Résumé + if resume: + add_text_box(slide, left + 0.30, top + 4.0, + sidebar_w - 0.60, 3.0, + resume, self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # CTA + if cta: + add_rect(slide, left + 0.30, top + height - 2.0, + sidebar_w - 0.60, 0.80, + self.theme["colors"]["primary"]["dark_blue"]) + add_text_box(slide, left + 0.30, top + height - 2.0, + sidebar_w - 0.60, 0.80, + cta, self._font_body, 9, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + + # Contenu principal à droite de la sidebar + content_left = left + sidebar_w + 0.50 + content_w = 33.87 - content_left - 0.50 + + # Header band + if headline: + add_rect(slide, content_left, top + 0.80, + content_w, 1.10, + self.theme["colors"]["primary"]["dark_blue"]) + add_text_box(slide, content_left + 0.30, top + 1.0, + content_w - 0.60, 0.70, + headline, self._font_body, 11, + bold=True, color="#ffffff") + + # Bullets + if bullets: + zone_fake = {"id": "main_content", "component": "C06", + "width_cm": content_w} + slide_fake = {"bullets": bullets} + self._render_bullet_list(slide, zone_fake, slide_fake, + content_left, top + 2.20, + content_w, height - 2.50) + + +# ───────────────────────────────────────────────────────────────────────────── +# CLI +# ───────────────────────────────────────────────────────────────────────────── + +def main(): + import argparse + parser = argparse.ArgumentParser( + description="Sliding render_engine — JSON → PPTX Pernod Ricard") + parser.add_argument("json_file", + help="Fichier JSON de la présentation (sortie Agent 3)") + parser.add_argument("output", + help="Chemin du fichier PPTX à générer") + parser.add_argument("--theme", + default="theme.yaml", + help="Chemin vers theme.yaml (défaut: ./theme.yaml)") + parser.add_argument("--components", + default="components.yaml", + help="Chemin vers components.yaml") + parser.add_argument("--layouts", + default="layouts.yaml", + help="Chemin vers layouts.yaml") + args = parser.parse_args() + + if not os.path.exists(args.json_file): + print(f"✗ Fichier JSON introuvable : {args.json_file}") + sys.exit(1) + for f in [args.theme, args.components, args.layouts]: + if not os.path.exists(f): + print(f"✗ Fichier YAML introuvable : {f}") + sys.exit(1) + + engine = RenderEngine(args.theme, args.components, args.layouts) + with open(args.json_file, encoding="utf-8") as f: + raw = f.read().strip() + if not raw: + print(f"\u2717 Fichier d\'entr\u00e9e vide : {args.json_file}") + sys.exit(1) + # Accepte YAML ou JSON indiff\u00e9remment + try: + import yaml as _yaml + json_data = _yaml.safe_load(raw) + except Exception: + json_data = json.loads(raw) + engine.render(json_data, args.output) + + +if __name__ == "__main__": + main() diff --git a/archive/render_engine_v1.11.py b/archive/render_engine_v1.11.py new file mode 100644 index 0000000..fe6b931 --- /dev/null +++ b/archive/render_engine_v1.11.py @@ -0,0 +1,1924 @@ +""" +render_engine.py — Sliding Design System · Pernod Ricard +========================================================= +Moteur de rendu générique JSON → PPTX. + +Usage : + from render_engine import RenderEngine + engine = RenderEngine("theme.yaml", "components.yaml", "layouts.yaml") + engine.render(json_data, "output.pptx") + +Ou en ligne de commande : + python render_engine.py presentation.json output.pptx + +Architecture : + RenderEngine.render() + └── pour chaque slide : + 1. _resolve_layout() → charge la config du layout + 2. _render_background() → fond (C01) + 3. _render_signature() → logo, barre d'accent, footer (C02-C05) + 4. _measure_title() → calcule hauteur réelle du titre + 5. _render_title() → place le titre (C02) + 6. pour chaque content_zone : + _measure_component() → hauteur réelle + _render_component() → dispatch vers le bon renderer +""" + +from __future__ import annotations + +import json +import math +import os +import sys +from pathlib import Path +from typing import Any + +import yaml +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.text import PP_ALIGN +from pptx.util import Cm, Pt, Emu +from pptx.dml.color import RGBColor +from pptx.oxml.ns import qn +from lxml import etree + + +# ───────────────────────────────────────────────────────────────────────────── +# CONSTANTES +# ───────────────────────────────────────────────────────────────────────────── + +CM = 360000 # 1 cm = 360 000 EMU +PT = 12700 # 1 pt = 12 700 EMU +SLIDE_W = 12192000 # 33.87 cm +SLIDE_H = 6858000 # 19.05 cm +FOOTER_TOP = 18.35 # cm +FOOTER_H = 0.70 # cm + + +# ───────────────────────────────────────────────────────────────────────────── +# UTILITAIRES +# ───────────────────────────────────────────────────────────────────────────── + +def cm(v: float) -> int: + """Centimètres → EMU.""" + return int(v * CM) + + +def pt(v: float) -> int: + """Points → EMU (pour line_spacing, etc.).""" + return int(v * PT) + + +def hex_to_rgb(h: str) -> RGBColor: + """'#rrggbb' → RGBColor.""" + h = h.lstrip("#") + return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) + + +def resolve_ref(value: str, theme: dict) -> str: + """ + Résout une référence theme.* dans une valeur YAML. + Ex: 'theme.colors.primary.rose' → '#ff9166' + Retourne la valeur brute si ce n'est pas une ref. + """ + if not isinstance(value, str) or not value.startswith("theme."): + return value + parts = value.split(".")[1:] # retire 'theme' + node = theme + for p in parts: + if isinstance(node, dict) and p in node: + node = node[p] + else: + return value # ref non résolue → retourne telle quelle + return node + + +def add_text_box(slide, left, top, width, height, + text, font_name, font_size_pt, bold=False, italic=False, + color="#000000", align=PP_ALIGN.LEFT, word_wrap=True): + """Ajoute une text box sur le slide. Retourne le shape.""" + txBox = slide.shapes.add_textbox(cm(left), cm(top), cm(width), cm(height)) + tf = txBox.text_frame + tf.word_wrap = word_wrap + p = tf.paragraphs[0] + p.alignment = align + run = p.add_run() + run.text = text + run.font.name = font_name + run.font.size = Pt(font_size_pt) + run.font.bold = bold + run.font.italic = italic + run.font.color.rgb = hex_to_rgb(color) + return txBox + + +def add_rect(slide, left, top, width, height, fill_color, border_color=None, border_width_cm=0): + """Ajoute un rectangle plein. Retourne le shape.""" + shape = slide.shapes.add_shape( + 1, # MSO_SHAPE_TYPE.RECTANGLE + cm(left), cm(top), cm(width), cm(height) + ) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(fill_color) + if border_color and border_width_cm > 0: + shape.line.color.rgb = hex_to_rgb(border_color) + shape.line.width = cm(border_width_cm) + else: + shape.line.fill.background() + return shape + + +def add_line(slide, x1, y1, x2, y2, color="#e8e2d6", width_cm=0.03): + """Ajoute une ligne.""" + from pptx.util import Emu + connector = slide.shapes.add_connector(1, cm(x1), cm(y1), cm(x2), cm(y2)) + connector.line.color.rgb = hex_to_rgb(color) + connector.line.width = cm(width_cm) + return connector + + +def estimate_text_height(text: str, font_size_pt: float, + box_width_cm: float, line_spacing: float = 1.15) -> float: + """ + Estime la hauteur en cm d'un texte dans une boîte. + Heuristique : ~2.2 caractères par cm de largeur à 11pt, scaled par font_size. + """ + chars_per_line = max(1, int(box_width_cm * 2.2 * (11 / font_size_pt))) + lines = 0 + for paragraph in text.split("\n"): + if not paragraph.strip(): + lines += 0.5 + continue + lines += math.ceil(len(paragraph) / chars_per_line) + line_height_cm = font_size_pt * 0.035 * line_spacing + return lines * line_height_cm + + +# ───────────────────────────────────────────────────────────────────────────── +# RENDER ENGINE +# ───────────────────────────────────────────────────────────────────────────── + +class RenderEngine: + """ + Moteur principal. Charge les 3 YAML, expose render(json_data, output_path). + """ + + def __init__(self, theme_path: str, components_path: str, layouts_path: str): + with open(theme_path, encoding="utf-8") as f: + self.theme = yaml.safe_load(f) + with open(components_path, encoding="utf-8") as f: + self.components = yaml.safe_load(f)["components"] + with open(layouts_path, encoding="utf-8") as f: + data = yaml.safe_load(f) + self.layouts = data["layouts"] + + # Polices résolues (avec fallback si non installées) + self._font_display = self._resolve_font("display") + self._font_body = self._resolve_font("body") + + # Cycle couleur (index global, remis à zéro par présentation) + self._cycle_index = 0 + + # ── Résolution des polices ───────────────────────────────────────────── + + def _resolve_font(self, role: str) -> str: + """Retourne le nom de police à utiliser (tentative + fallback).""" + font_cfg = self.theme["typography"][role] + primary = font_cfg["family"] + fallback = font_cfg["fallback"] + # On tente d'utiliser la police primaire. python-pptx l'intègre par + # nom — si elle n'est pas installée sur la machine cible, PowerPoint + # utilisera la police système la plus proche. + return primary # fallback géré à l'ouverture du PPTX côté utilisateur + + def _font(self, role: str) -> str: + return self._font_display if role == "display" else self._font_body + + # ── Résolution des refs theme ────────────────────────────────────────── + + def _r(self, value: Any) -> Any: + """Résout une ref theme.* si nécessaire.""" + return resolve_ref(value, self.theme) + + def _cycle_color(self) -> str: + colors = self.theme["colors"]["cycle"] + c = colors[self._cycle_index % len(colors)] + self._cycle_index += 1 + return c + + # ── Mesure ──────────────────────────────────────────────────────────── + + def _measure_title(self, layout_cfg: dict, slide_data: dict) -> float: + """ + Calcule la hauteur réelle occupée par le bloc titre + sous-titre. + Retourne la hauteur en cm. + """ + tz = layout_cfg.get("title_zone") + if not tz: + return 0.0 + + titre = slide_data.get("titre", "") + sous_titre = slide_data.get("sous_titre", "") + + font_override = tz.get("font_override", {}) + size_pt = font_override.get("size_pt", + self.theme["typography"]["sizes"]["slide_title"]) + width_cm = tz.get("width_cm", 30.0) + + h = estimate_text_height(titre, size_pt, width_cm, 1.1) + if sous_titre: + sub_size = tz.get("subtitle", {}).get("size_pt", + self.theme["typography"]["sizes"]["slide_subtitle"]) + h += estimate_text_height(sous_titre, sub_size, width_cm, 1.1) + h += 0.15 # marge entre titre et sous-titre + return max(h, 0.60) # minimum 0.6 cm + + def _measure_component(self, zone: dict, slide_data: dict) -> float: + """ + Estime la hauteur réelle d'une content_zone selon son contenu. + Retourne la hauteur en cm. Si non estimable, retourne height_cm du layout. + """ + comp_id = zone.get("component", "") + max_h = zone.get("height_cm", 14.0) + + # bullet_list + if comp_id == "C06": + bullets = slide_data.get("bullets", []) + if not bullets: + # cherche dans les sous-clés (two_cols, etc.) + return max_h + total_h = 0.0 + for b in bullets: + lvl = b.get("niveau", 1) + size_pt = [11, 10, 9][min(lvl - 1, 2)] + w = zone.get("width_cm", 28.0) + total_h += estimate_text_height( + b.get("texte", ""), size_pt, w - (lvl - 1) * 0.5) + total_h += [0.14, 0.08, 0.04][min(lvl - 1, 2)] + return min(total_h + 0.3, max_h) + + # text_paragraph (executive_summary blocs) + if comp_id == "C07": + # cherche le champ associé dans slide_data + for key in ["situation", "complication", "resolution", + "contenu", "description"]: + if key in slide_data: + txt = slide_data[key] + h = estimate_text_height(txt, 11, zone.get("width_cm", 28.0)) + return min(h + 0.6, max_h) # +0.6 pour le titre de bloc + return max_h + + # kpi_grid → hauteur calculée selon nb items + if comp_id == "C09": + items = slide_data.get("items", []) + n = len(items) + grid = {2: (2, 1), 3: (3, 1), 4: (2, 2), 5: (3, 2), 6: (3, 2)} + cols, rows = grid.get(n, (3, 2)) + card_h = 4.5 # hauteur d'une carte KPI en cm + gap = 0.4 + return min(rows * card_h + (rows - 1) * gap, max_h) + + # big_stat → hauteur fixe + if comp_id == "C10": + return max_h + + # Pour tous les autres composants visuels complexes → hauteur max + return max_h + + # ── Rendu principal ─────────────────────────────────────────────────── + + def render(self, json_data: dict | str, output_path: str): + """ + Point d'entrée. Accepte un dict ou une chaîne JSON. + Produit le fichier PPTX à output_path. + """ + if isinstance(json_data, str): + json_data = json.loads(json_data) + + prs = Presentation() + prs.slide_width = Emu(SLIDE_W) + prs.slide_height = Emu(SLIDE_H) + + # Supprime les layouts par défaut (on dessine tout manuellement) + blank_layout = prs.slide_layouts[6] # layout "blank" + + self._cycle_index = 0 + slides = json_data.get("slides", []) + + for i, slide_data in enumerate(slides): + slide = prs.slides.add_slide(blank_layout) + layout_name = slide_data.get("layout", "default_bullets") + self._render_slide(slide, slide_data, layout_name, i + 1, len(slides)) + + prs.save(output_path) + print(f"✓ PPTX généré : {output_path} ({len(slides)} slides)") + + def _render_slide(self, slide, slide_data: dict, layout_name: str, + slide_num: int, total: int): + """Orchestre le rendu d'un slide complet.""" + layout_cfg = self.layouts.get(layout_name) + if not layout_cfg: + print(f" ⚠ Layout inconnu '{layout_name}' → fallback default_bullets") + layout_cfg = self.layouts["default_bullets"] + layout_name = "default_bullets" + + # ── 1. Background ───────────────────────────────────────────────── + self._render_background(slide, layout_cfg) + + # ── 2. Signature (footer, logo, accent bar) ─────────────────────── + self._render_footer(slide, layout_name, slide_num) + self._render_logo(slide, layout_name) + + # ── 3. Titre + mesure ───────────────────────────────────────────── + title_h = self._measure_title(layout_cfg, slide_data) + title_bottom = self._render_title(slide, layout_cfg, slide_data, title_h) + self._render_accent_bar(slide, layout_name, title_h) + + # ── 4. Content zones ────────────────────────────────────────────── + zones = layout_cfg.get("content_zones") or [] + # cursor : commence juste sous le titre + cursor_y = title_bottom + 0.20 if title_bottom else 2.80 + # zone max disponible (jusqu'au footer ou bas du slide) + max_bottom = FOOTER_TOP - 0.30 # laisse 0.3 cm au-dessus du footer + + for zone in zones: + # Zones optionnelles absentes du JSON → skip + if zone.get("optional") and not self._zone_has_data(zone, slide_data): + continue + + # Positions : priorité aux coords fixes, sinon on utilise le curseur + z_left = zone.get("left_cm", 1.50) + z_top = zone.get("top_cm", cursor_y) + z_width = zone.get("width_cm", 30.87) + + # Calcul de la hauteur réelle + measured_h = self._measure_component(zone, slide_data) + z_height = min(measured_h, max_bottom - z_top) + if z_height <= 0: + continue # plus de place + + # Mise à jour du curseur (uniquement pour les zones sans top fixe) + if "top_cm" not in zone: + cursor_y = z_top + z_height + 0.25 + + self._render_zone(slide, zone, slide_data, + z_left, z_top, z_width, z_height) + + # ── Background ──────────────────────────────────────────────────────── + + def _render_background(self, slide, layout_cfg: dict): + """Rend le fond du slide (C01).""" + bg = layout_cfg.get("background", {}) + color = self._r(bg.get("color", "#ffffff")) + + if bg.get("diagonal_split"): + color_right = self._r(bg.get("color_right", "#023466")) + angle = bg.get("diagonal_angle_deg", 15) + self._render_diagonal_background(slide, color, color_right, angle) + else: + add_rect(slide, 0, 0, 33.87, 19.05, color) + + def _render_diagonal_background(self, slide, color_left: str, + color_right: str, angle_deg: float): + """Fond splitté diagonal : rectangle gauche + triangle droit.""" + # Panneau gauche plein + add_rect(slide, 0, 0, 33.87, 19.05, color_left) + # Panneau droit via freeform (triangle) + # La diagonale va du point (split_x, 0) au point (split_x - offset, 19.05) + split_x = 20.0 # cm — point haut de la diagonale + offset = 19.05 * math.tan(math.radians(angle_deg)) + split_x_bottom = split_x - offset + + from pptx.util import Emu + from pptx.oxml.ns import qn + + # Utilise add_shape freeform via XML pour le triangle + sp = slide.shapes.add_shape(1, + cm(split_x_bottom), cm(0), + cm(33.87 - split_x_bottom), cm(19.05)) + sp.fill.solid() + sp.fill.fore_color.rgb = hex_to_rgb(color_right) + sp.line.fill.background() + + # Note : python-pptx ne supporte pas les freeforms nativement. + # Pour un vrai triangle, il faudrait manipuler l'XML OOXML directement. + # Cette version utilise un rectangle approché — suffisant pour l'aperçu. + # TODO : implémenter la forme triangulaire via lxml si rendu exact requis. + + # ── Signature ───────────────────────────────────────────────────────── + + def _render_footer(self, slide, layout_name: str, slide_num: int): + """Rend le footer PR (C04).""" + footer_cfg = self.theme["signature"]["footer"] + hidden_on = footer_cfg.get("hidden_on", []) + if layout_name in hidden_on: + return + + top = FOOTER_TOP + h = FOOTER_H + w = 33.87 + + # Fond blanc + add_rect(slide, 0, top, w, h, "#ffffff") + # Bordure top + add_line(slide, 0, top, w, top, "#e8e2d6", 0.03) + + # Numéro de slide + add_text_box(slide, 0.80, top + 0.10, 1.50, 0.50, + str(slide_num), self._font_body, 8, + color="#000a32", align=PP_ALIGN.LEFT) + + # Séparateur vertical + add_line(slide, 1.50, top + 0.10, 1.50, top + 0.60, "#7fa5d0", 0.03) + + # "Pernod Ricard" + add_text_box(slide, 1.70, top + 0.10, 5.00, 0.50, + "Pernod Ricard", self._font_body, 8, + color="#000a32", align=PP_ALIGN.LEFT) + + # Tagline à droite + add_text_box(slide, 15.00, top + 0.10, 18.00, 0.50, + "DATA GOVERNANCE DATA MANAGEMENT", + self._font_body, 7, + color="#48545a", align=PP_ALIGN.RIGHT) + + def _render_logo(self, slide, layout_name: str): + """Insère le logo PR top-left si le fichier assets/logo_pr_sun.png existe.""" + logo_cfg = self.theme["signature"]["logo_topbar"] + visible_on = logo_cfg.get("visible_on", []) + if layout_name not in visible_on: + return + + logo_path = logo_cfg.get("file", "assets/logo_pr_sun.png") + if not os.path.exists(logo_path): + # Logo absent → on dessine un proxy (cercle orange petit) + shape = slide.shapes.add_shape(9, # ellipse + cm(logo_cfg["position_left_cm"]), + cm(logo_cfg["position_top_cm"]), + cm(logo_cfg["height_cm"]), + cm(logo_cfg["height_cm"])) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb("#ff9166") + shape.line.fill.background() + return + + slide.shapes.add_picture( + logo_path, + cm(logo_cfg["position_left_cm"]), + cm(logo_cfg["position_top_cm"]), + height=cm(logo_cfg["height_cm"]) + ) + + def _render_accent_bar(self, slide, layout_name: str, title_h: float): + """Barre verticale rose à gauche du titre (C03).""" + sig = self.theme["signature"]["accent_bar"] + if layout_name not in sig.get("visible_on", []): + return + + bar_h = max(title_h, 0.60) + add_rect(slide, + sig["position_left_cm"], 0.45, + sig["width_cm"], bar_h, + sig["color"]) + + # ── Titre ───────────────────────────────────────────────────────────── + + def _render_title(self, slide, layout_cfg: dict, + slide_data: dict, title_h: float) -> float: + """Rend le titre et le sous-titre. Retourne le y_bottom en cm.""" + tz = layout_cfg.get("title_zone") + if not tz: + return 0.0 + + titre = slide_data.get("titre", "") + sous_titre = slide_data.get("sous_titre", "") + + left = tz.get("left_cm", 1.80) + top = tz.get("top_cm", 0.45) + width = tz.get("width_cm", 30.00) + + font_override = tz.get("font_override", {}) + size_pt = font_override.get("size_pt", + self.theme["typography"]["sizes"]["slide_title"]) + bold = font_override.get("bold", True) + color = self._r(font_override.get("color", + self.theme["colors"]["text"]["on_white"])) + + # Titre principal + h_titre = estimate_text_height(titre, size_pt, width, 1.1) + h_titre = max(h_titre, size_pt * 0.035 + 0.1) + add_text_box(slide, left, top, width, h_titre + 0.20, + titre, self._font_display, size_pt, + bold=bold, color=color) + + current_bottom = top + h_titre + 0.20 + + # Sous-titre + if sous_titre: + sub_cfg = tz.get("subtitle", {}) + sub_size = sub_cfg.get("size_pt", + self.theme["typography"]["sizes"]["slide_subtitle"]) + sub_color = self._r(sub_cfg.get("color", + self.theme["colors"]["text"]["subtitle"])) + margin = sub_cfg.get("margin_top_cm", 0.10) + add_text_box(slide, left, current_bottom + margin, + width, 0.60, + sous_titre, self._font_body, sub_size, + color=sub_color) + current_bottom += margin + 0.60 + + return current_bottom + + # ── Dispatch des zones ──────────────────────────────────────────────── + + def _zone_has_data(self, zone: dict, slide_data: dict) -> bool: + """Vérifie si une zone optionnelle a des données dans le JSON.""" + comp = zone.get("component", "") + if comp == "C07": + return any(k in slide_data for k in + ["description", "situation", "complication", "resolution", "contenu"]) + return True + + def _render_zone(self, slide, zone: dict, slide_data: dict, + left: float, top: float, width: float, height: float): + """Dispatche vers le renderer du composant.""" + comp = zone.get("component", "") + zone_type = zone.get("type", "") + + # Séparateurs (pas de composant associé) + if zone_type == "vertical_line": + add_line(slide, zone.get("x_cm", left), + zone.get("top_cm", top), + zone.get("x_cm", left), + zone.get("top_cm", top) + zone.get("height_cm", height), + self._r(zone.get("color", "#e8e2d6")), + zone.get("width_cm", 0.03)) + return + if zone_type == "horizontal_line": + y = zone.get("y_cm", top) + add_line(slide, left, y, left + width, y, + self._r(zone.get("color", "#e8e2d6")), + zone.get("width_cm", 0.03)) + return + + dispatch = { + "C06": self._render_bullet_list, + "C07": self._render_text_paragraph, + "C08": self._render_quote_block, + "C09": self._render_kpi_grid, + "C10": self._render_big_stat, + "C11": self._render_data_table, + "C12": self._render_chart_placeholder, + "C13": self._render_callout_box, + "C14": self._render_benchmark, + "C15": self._render_matrix_2x2, + "C16": self._render_pyramid, + "C17": self._render_circular_diagram, + "C18": self._render_from_to_pairs, + "C19": self._render_numbered_steps, + "C20": self._render_chevrons, + "C21": self._render_gantt, + "C22": self._render_timeline, + "C23": self._render_org_chart, + "C24": self._render_raci, + "C25": self._render_decision_tree, + "C26": self._render_recommendation_sidebar, + } + + renderer = dispatch.get(comp) + if renderer: + renderer(slide, zone, slide_data, left, top, width, height) + else: + # Composant inconnu → zone grise placeholder + self._render_placeholder(slide, left, top, width, height, comp) + + # ── Renderers des composants ────────────────────────────────────────── + + def _render_placeholder(self, slide, left, top, width, height, label="?"): + """Zone placeholder pour composants non encore implémentés.""" + add_rect(slide, left, top, width, height, "#f5f1ea") + add_text_box(slide, left + 0.5, top + height / 2 - 0.3, + width - 1, 0.6, + f"[ {label} — à implémenter ]", + self._font_body, 10, color="#9a9a9a", + align=PP_ALIGN.CENTER) + + # C06 — bullet_list ──────────────────────────────────────────────────── + + def _render_bullet_list(self, slide, zone, slide_data, + left, top, width, height): + """Bullets hiérarchisés L1/L2/L3.""" + # Cherche les bullets dans le JSON (champ direct ou dans une colonne) + zone_id = zone.get("id", "") + if "col_left" in zone_id: + col_data = slide_data.get("left", {}) + elif "col_right" in zone_id: + col_data = slide_data.get("right", {}) + else: + col_data = slide_data + + bullets = col_data.get("bullets", []) + if not bullets: + return + + txBox = slide.shapes.add_textbox( + cm(left), cm(top), cm(width), cm(height)) + tf = txBox.text_frame + tf.word_wrap = True + + sizes = {1: 11, 2: 10, 3: 9} + colors = { + 1: "#000a32", + 2: self.theme["colors"]["text"]["body"], + 3: self.theme["colors"]["text"]["body"], + } + indents = {1: 0, 2: 0.5, 3: 1.0} + markers = {1: "• ", 2: "– ", 3: "▪ "} + space_before = {1: Pt(4), 2: Pt(2), 3: Pt(1)} + + first = True + for b in bullets: + lvl = b.get("niveau", 1) + text = b.get("texte", "") + + p = tf.paragraphs[0] if first else tf.add_paragraph() + first = False + p.space_before = space_before.get(lvl, Pt(4)) + p.alignment = PP_ALIGN.LEFT + + # Indentation via l'XML (level) + pPr = p._p.get_or_add_pPr() + pPr.set("lvl", str(lvl - 1)) + + run = p.add_run() + run.text = markers[lvl] + text + run.font.name = self._font_body + run.font.size = Pt(sizes[lvl]) + run.font.bold = (lvl == 1) + run.font.color.rgb = hex_to_rgb(colors[lvl]) + + # Sous-items récursifs + for sub in b.get("sous_items", []) or []: + p2 = tf.add_paragraph() + p2.alignment = PP_ALIGN.LEFT + run2 = p2.add_run() + run2.text = " – " + sub + run2.font.name = self._font_body + run2.font.size = Pt(9) + run2.font.color.rgb = hex_to_rgb(self.theme["colors"]["text"]["body"]) + + # C07 — text_paragraph ───────────────────────────────────────────────── + + def _render_text_paragraph(self, slide, zone, slide_data, + left, top, width, height): + """Bloc de texte libre avec titre de bloc optionnel.""" + zone_id = zone.get("id", "") + + # Mapping zone_id → champ JSON + field_map = { + "bloc_situation": ("Situation", "situation"), + "bloc_complication": ("Complication", "complication"), + "bloc_resolution": ("Résolution", "resolution"), + "col_left": (None, "left"), + "col_right": (None, "right"), + "description_bloc": (None, "description"), + "contact": (None, "contacts"), + "next_steps": (None, "message"), + } + + titre_bloc, field = field_map.get(zone_id, (None, "contenu")) + titre_couleur = self._r(zone.get("titre_couleur", + self.theme["colors"]["primary"]["dark_blue"])) + font_override = zone.get("font_override", {}) + + cur_top = top + + # Titre de bloc + if titre_bloc: + add_text_box(slide, left, cur_top, width, 0.50, + titre_bloc, self._font_body, 13, + bold=True, color=titre_couleur) + cur_top += 0.55 + + # Contenu + raw = slide_data.get(field, "") + if isinstance(raw, dict): + titre_col = raw.get("titre", "") + contenu = raw.get("contenu", "") + if titre_col: + add_text_box(slide, left, cur_top, width, 0.45, + titre_col, self._font_body, 13, + bold=True, color=titre_couleur) + cur_top += 0.50 + raw = contenu + + if raw: + color = font_override.get("color", self.theme["colors"]["text"]["body"]) + size_pt = font_override.get("size_pt", 11) + add_text_box(slide, left, cur_top, width, + height - (cur_top - top), + str(raw), self._font_body, size_pt, + color=color) + + # C08 — quote_block ──────────────────────────────────────────────────── + + def _render_quote_block(self, slide, zone, slide_data, + left, top, width, height): + """Citation / key message avec guillemets Cormorant.""" + citation = slide_data.get("message") or slide_data.get("citation", "") + auteur = slide_data.get("auteur", "") + fonction = slide_data.get("fonction", "") + + # Guillemet décoratif + add_text_box(slide, left, top + 0.3, 2.0, 1.5, + "\u201C", self._font_display, 72, + color=self.theme["colors"]["primary"]["bright_blue"]) + + # Message + add_text_box(slide, left + 1.5, top + 1.2, + width - 1.5, height - 2.0, + citation, self._font_display, 22, + color=self.theme["colors"]["primary"]["dark_blue"]) + + # Attribution + if auteur or fonction: + attr = f"{auteur} {fonction}".strip() + add_text_box(slide, left + 1.5, + top + height - 1.2, + width - 1.5, 0.60, + attr, self._font_body, 10, + color=self.theme["colors"]["text"]["body"]) + + # C09 — kpi_grid ─────────────────────────────────────────────────────── + + def _render_kpi_grid(self, slide, zone, slide_data, + left, top, width, height): + """Grille de cartes KPI adaptative.""" + items = slide_data.get("items", []) + if not items: + return + + n = len(items) + grid = {2: (2, 1), 3: (3, 1), 4: (2, 2), 5: (3, 2), 6: (3, 2)} + cols, rows = grid.get(n, (3, 2)) + gap = 0.40 + + card_w = (width - (cols - 1) * gap) / cols + card_h = (height - (rows - 1) * gap) / rows + + for i, item in enumerate(items): + col = i % cols + row = i // cols + x = left + col * (card_w + gap) + y = top + row * (card_h + gap) + + color = item.get("couleur") or self._cycle_color() + color = self._r(color) + header_h = 0.55 + + # Header coloré + add_rect(slide, x, y, card_w, header_h, color) + add_text_box(slide, x + 0.2, y + 0.10, + card_w - 0.4, header_h - 0.10, + item.get("titre", ""), + self._font_body, 9, + bold=True, color="#ffffff") + + # Body beige + add_rect(slide, x, y + header_h, card_w, + card_h - header_h, + self.theme["colors"]["backgrounds"]["content_area"]) + + # Valeur en gros + val_h = card_h - header_h - 1.0 + add_text_box(slide, x + 0.2, y + header_h + 0.3, + card_w - 0.4, val_h, + item.get("valeur", ""), + self._font_display, 32, + bold=True, + color=self.theme["colors"]["primary"]["rose"], + align=PP_ALIGN.CENTER) + + # Sous-titre + if item.get("sous_titre"): + add_text_box(slide, x + 0.2, + y + card_h - 0.8, + card_w - 0.4, 0.70, + item["sous_titre"], + self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # C10 — big_stat_display ─────────────────────────────────────────────── + + def _render_big_stat(self, slide, zone, slide_data, + left, top, width, height): + """Chiffre unique centré en très grand format.""" + valeur = slide_data.get("valeur", "") + label = slide_data.get("label", "") + source = slide_data.get("source", "") + + center_top = top + (height - 4.0) / 2 + + # Valeur + add_text_box(slide, left, center_top, width, 2.80, + valeur, self._font_display, 72, + bold=True, + color=self.theme["colors"]["primary"]["rose"], + align=PP_ALIGN.CENTER) + + if label: + add_text_box(slide, left, center_top + 2.90, width, 0.70, + label, self._font_body, 11, + color=self.theme["colors"]["text"]["body"], + align=PP_ALIGN.CENTER) + if source: + add_text_box(slide, left, center_top + 3.70, width, 0.50, + f"Source : {source}", self._font_body, 9, + color=self.theme["colors"]["text"]["caption"], + align=PP_ALIGN.CENTER) + + # C11 — data_table ───────────────────────────────────────────────────── + + def _render_data_table(self, slide, zone, slide_data, + left, top, width, height): + """Tableau structuré avec header bleu foncé et lignes alternées.""" + headers = slide_data.get("headers", []) + rows = slide_data.get("rows", []) + if not headers: + return + + highlight_col = slide_data.get("highlight_col") + col_widths_pct = slide_data.get("col_widths") + + n_cols = len(headers) + header_h = 0.65 + available_h = height - header_h + row_h = min(available_h / max(len(rows), 1), 0.80) + + # Largeurs de colonnes + if col_widths_pct: + col_widths = [w * width for w in col_widths_pct] + else: + col_widths = [width / n_cols] * n_cols + + # Header + x = left + for j, h in enumerate(headers): + add_rect(slide, x, top, col_widths[j], header_h, + self.theme["colors"]["primary"]["dark_blue"]) + add_text_box(slide, x + 0.15, top + 0.10, + col_widths[j] - 0.3, header_h - 0.15, + str(h), self._font_body, 9, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + x += col_widths[j] + + # Lignes + odd_bg = "#ffffff" + even_bg = self.theme["colors"]["backgrounds"]["content_area"] + highlight_bg = self.theme["colors"]["backgrounds"]["highlight_box"] + + for i, row in enumerate(rows): + y = top + header_h + i * row_h + x = left + for j, cell in enumerate(row): + bg = highlight_bg if j == highlight_col else ( + odd_bg if i % 2 == 0 else even_bg) + add_rect(slide, x, y, col_widths[j], row_h, bg) + add_text_box(slide, x + 0.15, y + 0.08, + col_widths[j] - 0.3, row_h - 0.10, + str(cell), self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + x += col_widths[j] + # Ligne séparatrice + add_line(slide, left, y + row_h, left + width, y + row_h, + "#e8e2d6", 0.02) + + # C12 — chart_placeholder ────────────────────────────────────────────── + + def _render_chart_placeholder(self, slide, zone, slide_data, + left, top, width, height): + """ + Graphique simplifié (bar chart) généré avec python-pptx Chart. + Pour un rendu avancé, remplacer par openpyxl + pptx chart data. + """ + from pptx.chart.data import ChartData + from pptx.enum.chart import XL_CHART_TYPE + + data_items = slide_data.get("data", []) + chart_type = slide_data.get("chart_type", "bar") + if not data_items: + self._render_placeholder(slide, left, top, width, height, "C12 chart") + return + + chart_data = ChartData() + chart_data.categories = [str(d.get("label", f"Item {i+1}")) + for i, d in enumerate(data_items)] + chart_data.add_series("", [float(d.get("valeur", 0)) + for d in data_items]) + + xl_type = { + "bar": XL_CHART_TYPE.BAR_CLUSTERED, + "line": XL_CHART_TYPE.LINE, + "pie": XL_CHART_TYPE.PIE, + "donut": XL_CHART_TYPE.DOUGHNUT, + }.get(chart_type, XL_CHART_TYPE.BAR_CLUSTERED) + + chart = slide.shapes.add_chart( + xl_type, + cm(left), cm(top), cm(width), cm(height), + chart_data + ).chart + + # Supprimer le titre du chart (on a déjà le titre du slide) + chart.has_title = False + chart.has_legend = False + + # Couleur des barres + series = chart.series[0] + fill = series.format.fill + fill.solid() + fill.fore_color.rgb = hex_to_rgb( + self.theme["colors"]["primary"]["bright_blue"]) + + # C13 — callout_box ──────────────────────────────────────────────────── + + def _render_callout_box(self, slide, zone, slide_data, + left, top, width, height): + """Encadré d'insight jaune.""" + insight = slide_data.get("insight", "") + titre = slide_data.get("titre_insight", "") + + # Fond + add_rect(slide, left, top, width, height, + self.theme["colors"]["backgrounds"]["highlight_box"], + self.theme["colors"]["secondary"]["maize_yellow"], 0.05) + + cur_top = top + 0.30 + if titre: + add_text_box(slide, left + 0.30, cur_top, + width - 0.60, 0.50, + titre, self._font_body, 12, + bold=True, + color=self.theme["colors"]["primary"]["rose"]) + cur_top += 0.55 + + if insight: + add_text_box(slide, left + 0.30, cur_top, + width - 0.60, height - (cur_top - top) - 0.30, + insight, self._font_body, 10, + color=self.theme["colors"]["text"]["body"]) + + # C14 — benchmark_bar ────────────────────────────────────────────────── + + def _render_benchmark(self, slide, zone, slide_data, + left, top, width, height): + """Barres horizontales de benchmark.""" + criteria = slide_data.get("criteria", []) + actors = slide_data.get("actors", []) + scores = slide_data.get("scores", []) + if not criteria or not actors: + return + + colors_actors = slide_data.get("couleurs_acteurs") or [ + self.theme["colors"]["primary"]["bright_blue"], + self.theme["colors"]["primary"]["rose"], + self.theme["colors"]["secondary"]["barley_green"], + self.theme["colors"]["secondary"]["maize_yellow"], + ] + + n_crit = len(criteria) + n_act = len(actors) + label_w = 5.00 + bar_area_w = width - label_w + row_h = height / n_crit + bar_h = 0.30 + bar_gap = 0.10 + + # Légende acteurs (en haut) + for j, actor in enumerate(actors): + add_rect(slide, left + label_w + j * 2.0, top - 0.50, + 0.25, 0.25, colors_actors[j % len(colors_actors)]) + add_text_box(slide, left + label_w + j * 2.0 + 0.30, + top - 0.55, 1.5, 0.35, + actor, self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + for i, crit in enumerate(criteria): + y = top + i * row_h + + # Label critère + add_text_box(slide, left, y + row_h / 2 - 0.20, + label_w - 0.30, 0.40, + crit, self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # Barres par acteur + for j in range(n_act): + score = 0 + if i < len(scores) and j < len(scores[i]): + score = float(scores[i][j]) + bar_w = (score / 100) * bar_area_w + + bar_y = y + (row_h - n_act * (bar_h + bar_gap)) / 2 + j * (bar_h + bar_gap) + add_rect(slide, left + label_w, bar_y, + max(bar_w, 0.05), bar_h, + colors_actors[j % len(colors_actors)]) + + # C15 — matrix_bubble ───────────────────────────────────────────────── + + def _render_matrix_2x2(self, slide, zone, slide_data, + left, top, width, height): + """Matrice 2×2 avec bulles positionnées.""" + axis_x = slide_data.get("axis_x", {}) + axis_y = slide_data.get("axis_y", {}) + items = slide_data.get("items", []) + + ax_label = str(axis_x.get("label", "")) + ay_label = str(axis_y.get("label", "")) + + # Marges pour les labels d'axes + margin_left = 1.50 + margin_bottom = 0.80 + plot_w = width - margin_left + plot_h = height - margin_bottom + + # Axes + add_line(slide, left + margin_left, top, + left + margin_left, top + plot_h, + "#000a32", 0.05) + add_line(slide, left + margin_left, top + plot_h, + left + width, top + plot_h, + "#000a32", 0.05) + + # Lignes de quadrant + mid_x = left + margin_left + plot_w / 2 + mid_y = top + plot_h / 2 + add_line(slide, mid_x, top, mid_x, top + plot_h, "#48545a", 0.02) + add_line(slide, left + margin_left, mid_y, + left + width, mid_y, "#48545a", 0.02) + + # Labels axes + add_text_box(slide, left + margin_left + plot_w / 2 - 2, + top + plot_h + 0.10, + 4, 0.40, ax_label, + self._font_body, 9, color="#48545a", + align=PP_ALIGN.CENTER) + add_text_box(slide, left, top + plot_h / 2 - 0.30, + margin_left - 0.10, 0.60, ay_label, + self._font_body, 9, color="#48545a", + align=PP_ALIGN.RIGHT) + + # Labels extremes + add_text_box(slide, left + margin_left - 0.5, top + plot_h - 0.20, + 0.8, 0.30, "Low", self._font_body, 8, color="#48545a") + add_text_box(slide, left + margin_left - 0.5, top, + 0.8, 0.30, "High", self._font_body, 8, color="#48545a") + add_text_box(slide, left + margin_left, top + plot_h, + 0.8, 0.30, "Low", self._font_body, 8, color="#48545a") + add_text_box(slide, left + width - 1.0, top + plot_h, + 1.0, 0.30, "High", self._font_body, 8, color="#48545a", + align=PP_ALIGN.RIGHT) + + colors = self.theme["colors"]["cycle"] + for i, item in enumerate(items): + x_pct = item.get("x", 50) / 100 + y_pct = 1 - item.get("y", 50) / 100 # inverser y (0 = bas) + size_factor = item.get("taille", 2) + diameter = 0.30 + (size_factor - 1) * 0.15 + color = self._r(item.get("couleur") or colors[i % len(colors)]) + + bx = left + margin_left + x_pct * plot_w - diameter / 2 + by = top + y_pct * plot_h - diameter / 2 + + shape = slide.shapes.add_shape(9, # ellipse + cm(bx), cm(by), cm(diameter), cm(diameter)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(color) + shape.fill.fore_color.theme_color + shape.line.fill.background() + # Opacité via XML + spPr = shape._element.spPr + solidFill = spPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}solidFill') + if solidFill is not None: + srgbClr = solidFill.find('{http://schemas.openxmlformats.org/drawingml/2006/main}srgbClr') + if srgbClr is not None: + alpha = etree.SubElement(srgbClr, + '{http://schemas.openxmlformats.org/drawingml/2006/main}alpha') + alpha.set('val', '75000') # 75% opacité + + # Label + add_text_box(slide, bx - 0.5, by + diameter + 0.05, + diameter + 1.0, 0.35, + item.get("label", ""), + self._font_body, 8, + color=self.theme["colors"]["primary"]["dark_blue"], + align=PP_ALIGN.CENTER) + + # C16 — pyramid_level ───────────────────────────────────────────────── + + def _render_pyramid(self, slide, zone, slide_data, + left, top, width, height): + """Pyramide hiérarchique.""" + levels = slide_data.get("levels", []) + if not levels: + return + + n = len(levels) + colors_default = [ + "#7fa5d0", "#000a32", "#bad9ff", "#ffcf0f", "#d9d9c4" + ] + level_h = height / n + center_x = left + width / 2 + max_w = width * 0.55 + callout_right_x = left + width * 0.70 + + for i, level in enumerate(levels): + rank = i + 1 + frac = rank / n + lvl_w = max_w * frac + lvl_left = center_x - lvl_w / 2 + lvl_top = top + i * level_h + color = self._r(level.get("couleur") or colors_default[i % len(colors_default)]) + + add_rect(slide, lvl_left, lvl_top, lvl_w, level_h - 0.05, color) + add_text_box(slide, lvl_left, lvl_top + level_h / 2 - 0.20, + lvl_w, 0.40, + level.get("label", ""), + self._font_body, 9, + bold=True, + color="#ffffff" if i in [1] else "#000a32", + align=PP_ALIGN.CENTER) + + # Callout latéral + if level.get("description"): + side = "right" if i % 2 == 0 else "left" + if side == "right": + add_line(slide, lvl_left + lvl_w, lvl_top + level_h / 2, + callout_right_x, lvl_top + level_h / 2, + "#48545a", 0.02) + add_text_box(slide, callout_right_x + 0.10, + lvl_top + level_h / 2 - 0.20, + left + width - callout_right_x - 0.20, + 0.60, level["description"], + self._font_body, 8, + color=self.theme["colors"]["text"]["body"]) + else: + callout_left_x = left + add_line(slide, lvl_left, lvl_top + level_h / 2, + callout_left_x + width * 0.25, + lvl_top + level_h / 2, "#48545a", 0.02) + add_text_box(slide, callout_left_x, + lvl_top + level_h / 2 - 0.20, + width * 0.24, 0.60, + level["description"], + self._font_body, 8, + color=self.theme["colors"]["text"]["body"], + align=PP_ALIGN.RIGHT) + + # C17 — circular_segment ─────────────────────────────────────────────── + + def _render_circular_diagram(self, slide, zone, slide_data, + left, top, width, height): + """Diagramme circulaire (approximation par secteurs via rectangles colorés).""" + segments = slide_data.get("segments", []) + if not segments: + return + + colors_default = self.theme["colors"]["cycle"] + n = len(segments) + + # Cercle central approximé (zones colorées en 2×N) + # Note : python-pptx ne supporte pas les pie charts custom facilement. + # On utilise des ellipses + médaillon central. + cx = left + width * 0.35 + cy = top + height / 2 + r = min(height * 0.38, width * 0.25) + + # Secteurs simulés par des rectangles colorés en arc + # (approximation visuelle — pour un vrai pie, utiliser chart_data) + angle_step = 360 / n + for i, seg in enumerate(segments): + color = self._r(seg.get("couleur") or colors_default[i % len(colors_default)]) + # Ellipse approximant un secteur + angle_rad = math.radians(i * angle_step) + sx = cx + r * 0.5 * math.cos(angle_rad) + sy = cy + r * 0.5 * math.sin(angle_rad) + shape = slide.shapes.add_shape(9, + cm(sx - r * 0.45), cm(sy - r * 0.45), + cm(r * 0.90), cm(r * 0.90)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(color) + shape.line.fill.background() + + # Médaillon central blanc + shape = slide.shapes.add_shape(9, + cm(cx - r * 0.35), cm(cy - r * 0.35), + cm(r * 0.70), cm(r * 0.70)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb("#ffffff") + shape.line.fill.background() + + # Légende à droite + leg_left = left + width * 0.55 + leg_top = top + (height - n * 1.4) / 2 + + for i, seg in enumerate(segments): + color = self._r(seg.get("couleur") or colors_default[i % len(colors_default)]) + y = leg_top + i * 1.40 + + # Pastille + shape = slide.shapes.add_shape(9, + cm(leg_left), cm(y + 0.05), + cm(0.35), cm(0.35)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(color) + shape.line.fill.background() + + # Num + label + add_text_box(slide, leg_left + 0.50, y, + width - (leg_left - left) - 0.60, 0.40, + f"0{i+1} {seg.get('label', '')}", + self._font_body, 10, bold=True, + color=self.theme["colors"]["primary"]["dark_blue"]) + if seg.get("description"): + add_text_box(slide, leg_left + 0.50, y + 0.42, + width - (leg_left - left) - 0.60, 0.70, + seg["description"], + self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # C18 — from_to_pair ─────────────────────────────────────────────────── + + def _render_from_to_pairs(self, slide, zone, slide_data, + left, top, width, height): + """Paires FROM → TO.""" + pairs = slide_data.get("pairs", []) + if not pairs: + return + + # En-tête FROM / TO + mid_x = left + width * 0.42 + add_text_box(slide, left, top, width * 0.40, 0.50, + "FROM", self._font_body, 11, + bold=True, + color=self.theme["semantic"]["arrow_color"]) + add_text_box(slide, mid_x + 0.80, top, width * 0.40, 0.50, + "TO", self._font_body, 11, + bold=True, + color=self.theme["semantic"]["arrow_color"]) + + row_h = (height - 0.60) / max(len(pairs), 1) + for i, pair in enumerate(pairs): + y = top + 0.60 + i * row_h + # FROM (atténué) + add_text_box(slide, left, y + 0.08, + width * 0.38, row_h - 0.15, + pair.get("from", ""), + self._font_body, 11, + color=self.theme["semantic"]["from_color"]) + + # Flèche + add_text_box(slide, mid_x - 0.20, y + 0.05, 0.60, 0.40, + "›", self._font_body, 18, bold=True, + color=self.theme["semantic"]["arrow_color"], + align=PP_ALIGN.CENTER) + + # TO (affirmé) + add_text_box(slide, mid_x + 0.50, y + 0.08, + width - mid_x - 0.50, row_h - 0.15, + pair.get("to", ""), + self._font_body, 11, + bold=True, + color=self.theme["semantic"]["to_color"]) + + # Séparateur + if i < len(pairs) - 1: + add_line(slide, left, y + row_h, + left + width, y + row_h, + "#e8e2d6", 0.02) + + # C19 — step_item ────────────────────────────────────────────────────── + + def _render_numbered_steps(self, slide, zone, slide_data, + left, top, width, height): + """Étapes numérotées verticalement.""" + steps = slide_data.get("steps", []) + if not steps: + return + + row_h = height / max(len(steps), 1) + badge_size = 0.70 + + for i, step in enumerate(steps): + y = top + i * row_h + + # Badge carré + add_rect(slide, left, y + (row_h - badge_size) / 2, + badge_size, badge_size, + self.theme["colors"]["primary"]["dark_blue"]) + add_text_box(slide, left, y + (row_h - badge_size) / 2, + badge_size, badge_size, + str(step.get("numero", i + 1)), + self._font_body, 11, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + + # Titre + add_text_box(slide, left + badge_size + 0.25, + y + (row_h - badge_size) / 2, + width * 0.35, badge_size, + step.get("titre", ""), + self._font_body, 13, + bold=True, + color=self.theme["colors"]["primary"]["dark_blue"]) + + # Description + if step.get("description"): + add_text_box(slide, left + badge_size + 0.25 + width * 0.36, + y + (row_h - badge_size) / 2, + width - badge_size - 0.25 - width * 0.36, + badge_size, + step["description"], + self._font_body, 10, + color=self.theme["colors"]["text"]["body"]) + + # Séparateur + if i < len(steps) - 1: + add_line(slide, left, y + row_h, + left + width, y + row_h, + "#e8e2d6", 0.02) + + # C20 — chevron_step ─────────────────────────────────────────────────── + + def _render_chevrons(self, slide, zone, slide_data, + left, top, width, height): + """Chevrons horizontaux de process.""" + phases = slide_data.get("phases", []) + if not phases: + return + + n = len(phases) + tip = 0.40 # largeur de la pointe + chev_h = 1.00 + total_w = width - 0.50 + chev_w = total_w / n + bullets_top = top + chev_h + 0.30 + + for i, phase in enumerate(phases): + x = left + i * chev_w + is_active = phase.get("actif", False) + is_last = (i == n - 1) + + fill = (self.theme["colors"]["primary"]["dark_blue"] + if is_active else + self.theme["colors"]["primary"]["hague_grey"]) + + # Rectangle du chevron + add_rect(slide, x, top, chev_w - 0.10, chev_h, fill) + # Texte + add_text_box(slide, x + 0.20, top + 0.20, + chev_w - 0.60, 0.60, + phase.get("label", ""), + self._font_display, 13, + bold=True, + color=self.theme["colors"]["primary"]["rose"] + if is_active else "#ffffff", + align=PP_ALIGN.CENTER) + + # Durée sous le chevron + if phase.get("duree"): + add_text_box(slide, x, top + chev_h + 0.05, + chev_w, 0.30, + phase["duree"], + self._font_body, 8, + color=self.theme["colors"]["text"]["body"], + align=PP_ALIGN.CENTER) + + # Bullets sous la phase + if phase.get("bullets"): + for j, bullet in enumerate(phase["bullets"]): + add_text_box(slide, x + 0.15, + bullets_top + j * 0.55, + chev_w - 0.30, 0.50, + "• " + bullet, + self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # C21 — gantt_bar ────────────────────────────────────────────────────── + + def _render_gantt(self, slide, zone, slide_data, + left, top, width, height): + """Gantt simplifié.""" + period = slide_data.get("period", {}) + workstreams = slide_data.get("workstreams", []) + if not workstreams: + return + + label_w = zone.get("label_col_width_cm", 5.50) + header_h = zone.get("header_height_cm", 0.60) + stream_h = zone.get("workstream_height_cm", 2.80) + timeline_w = width - label_w + + # Parse période + def parse_ym(s): + parts = str(s).split("-") + return int(parts[0]) * 12 + int(parts[1]) if len(parts) == 2 else 0 + + p_start = parse_ym(period.get("start", "2026-01")) + p_end = parse_ym(period.get("end", "2026-12")) + total_months = max(p_end - p_start + 1, 1) + + # Header mois + import calendar + months_short = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + add_rect(slide, left + label_w, top, timeline_w, header_h, + self.theme["colors"]["backgrounds"]["content_area"]) + + for m in range(total_months): + mx = left + label_w + (m / total_months) * timeline_w + mw = timeline_w / total_months + ym = p_start + m + month_name = months_short[(ym - 1) % 12] + add_text_box(slide, mx, top + 0.08, mw, 0.40, + month_name, self._font_body, 7, + color="#48545a", align=PP_ALIGN.CENTER) + + colors = self.theme["colors"]["cycle"] + + for i, ws in enumerate(workstreams): + y = top + header_h + i * stream_h + color = colors[i % len(colors)] + + # Label workstream (optionnel) + if ws.get("label"): + add_rect(slide, left, y, label_w - 0.20, stream_h - 0.10, + self.theme["colors"]["backgrounds"]["content_area"]) + add_text_box(slide, left + 0.15, y + stream_h / 2 - 0.20, + label_w - 0.40, 0.40, + ws["label"], self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + else: + add_rect(slide, left, y, label_w - 0.20, stream_h - 0.10, + "#e8e2d6") + + # Barres de tâches + for task in ws.get("tasks", []): + t_start = parse_ym(task.get("start", period.get("start"))) + t_end = parse_ym(task.get("end", period.get("end"))) + row = task.get("row", 1) + + offset_x = ((t_start - p_start) / total_months) * timeline_w + bar_w = max(((t_end - t_start + 1) / total_months) * timeline_w, 0.30) + bar_y = y + (row - 1) * (stream_h / 2) + 0.25 + bar_h = stream_h / 2 - 0.35 + + tc = self._r(task.get("couleur") or color) + add_rect(slide, left + label_w + offset_x, bar_y, + bar_w, bar_h, tc) + + # C22 — timeline_milestone ───────────────────────────────────────────── + + def _render_timeline(self, slide, zone, slide_data, + left, top, width, height): + """Timeline horizontale (yearly ou phases).""" + milestones = slide_data.get("milestones", []) + if not milestones: + # phases_timeline variant + self._render_phases_timeline(slide, zone, slide_data, left, top, width, height) + return + + n = len(milestones) + axis_y = top + height / 2 + spacing = width / (n + 1) + + # Axe + add_line(slide, left, axis_y, left + width, axis_y, "#48545a", 0.04) + # Flèche → + add_text_box(slide, left + width - 0.30, axis_y - 0.20, + 0.40, 0.40, "→", self._font_body, 10, color="#48545a") + + colors = self.theme["colors"] + for i, m in enumerate(milestones): + mx = left + (i + 1) * spacing + is_active = m.get("actif", False) + circle_color = (colors["primary"]["rose"] if is_active + else colors["primary"]["dark_blue"]) + year_color = (colors["primary"]["rose"] if is_active + else colors["primary"]["bright_blue"]) + + # Cercle sur l'axe + r = 0.18 + shape = slide.shapes.add_shape(9, + cm(mx - r), cm(axis_y - r), cm(r * 2), cm(r * 2)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(circle_color) + shape.line.fill.background() + + # Année au-dessus + add_text_box(slide, mx - 1.0, axis_y - 1.20, + 2.0, 0.50, str(m.get("annee", "")), + self._font_display, 13, + bold=True, color=year_color, + align=PP_ALIGN.CENTER) + + # Label + add_text_box(slide, mx - 1.5, axis_y + 0.30, + 3.0, 0.40, m.get("label", ""), + self._font_body, 9, + bold=True if is_active else False, + color=colors["primary"]["dark_blue"], + align=PP_ALIGN.CENTER) + + # Description + if m.get("description"): + add_text_box(slide, mx - 1.5, axis_y + 0.75, + 3.0, 0.70, m["description"], + self._font_body, 8, + color=colors["text"]["body"], + align=PP_ALIGN.CENTER) + + def _render_phases_timeline(self, slide, zone, slide_data, + left, top, width, height): + """Phases horizontales contiguës (L24).""" + phases = slide_data.get("phases", []) + if not phases: + return + + n = len(phases) + phase_h = 0.65 + period_h = 0.40 + colors = self.theme["colors"]["cycle"] + + # Largeur proportionnelle ou égale + phase_w = width / n + + for i, phase in enumerate(phases): + x = left + i * phase_w + color = colors[i % len(colors)] + + add_rect(slide, x, top, phase_w - 0.10, phase_h, color) + add_text_box(slide, x + 0.10, top + 0.10, + phase_w - 0.20, phase_h - 0.15, + phase.get("label", ""), + self._font_body, 8, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + + if phase.get("periode"): + add_text_box(slide, x, top + phase_h + 0.05, + phase_w, period_h, + phase["periode"], + self._font_body, 7, + color=self._r(color), + align=PP_ALIGN.CENTER) + + items = phase.get("items", []) + for j, item in enumerate(items): + add_text_box(slide, x + 0.10, + top + phase_h + period_h + 0.20 + j * 0.55, + phase_w - 0.20, 0.50, + "• " + item, + self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # C23 — org_node ─────────────────────────────────────────────────────── + + def _render_org_chart(self, slide, zone, slide_data, + left, top, width, height): + """Organigramme hiérarchique top-down.""" + root = slide_data.get("root", {}) + if not root: + return + + colors_by_level = [ + self.theme["colors"]["primary"]["dark_blue"], + self.theme["colors"]["secondary"]["barley_green"], + self.theme["colors"]["secondary"]["cool_blue"], + self.theme["colors"]["primary"]["bright_warm"], + ] + text_by_level = ["#ffffff", "#ffffff", "#000a32", "#000a32"] + + node_h = 0.65 + level_gap = 1.20 + + def draw_tree(node, level, x_center, y): + color = colors_by_level[min(level, len(colors_by_level) - 1)] + txt_color = text_by_level[min(level, len(text_by_level) - 1)] + node_w = max(3.0 - level * 0.3, 2.0) + + nx = x_center - node_w / 2 + add_rect(slide, nx, y, node_w, node_h, color) + add_text_box(slide, nx + 0.10, y + 0.12, + node_w - 0.20, node_h - 0.15, + node.get("label", ""), + self._font_body, 8, + bold=True, color=txt_color, + align=PP_ALIGN.CENTER) + + children = node.get("children", []) + if not children: + return + + nc = len(children) + child_span = min(width / max(nc, 1), 6.0) + children_total_w = child_span * nc + child_start_x = x_center - children_total_w / 2 + child_span / 2 + + child_y = y + node_h + level_gap + + # Ligne verticale descendante + add_line(slide, x_center, y + node_h, + x_center, y + node_h + level_gap / 2, + "#48545a", 0.03) + + # Ligne horizontale + add_line(slide, + child_start_x, y + node_h + level_gap / 2, + child_start_x + children_total_w - child_span, + y + node_h + level_gap / 2, + "#48545a", 0.03) + + for i, child in enumerate(children): + cx = child_start_x + i * child_span + add_line(slide, cx, y + node_h + level_gap / 2, + cx, child_y, "#48545a", 0.03) + draw_tree(child, level + 1, cx, child_y) + + draw_tree(root, 0, left + width / 2, top) + + # C24 — raci_cell ────────────────────────────────────────────────────── + + def _render_raci(self, slide, zone, slide_data, + left, top, width, height): + """Matrice RACI.""" + roles = slide_data.get("roles", []) + tasks = slide_data.get("tasks", []) + if not roles or not tasks: + return + + task_col_w = zone.get("task_col_width_cm", 8.00) + header_h = 0.65 + role_col_w = (width - task_col_w) / max(len(roles), 1) + row_h = min((height - header_h) / max(len(tasks), 1), 0.80) + + raci_colors = { + "R": self.theme["semantic"]["responsible"], + "A": self.theme["semantic"]["accountable"], + "C": self.theme["semantic"]["consulted"], + "I": self.theme["semantic"]["informed"], + } + + # Header + add_rect(slide, left, top, task_col_w, header_h, + self.theme["colors"]["backgrounds"]["content_area"]) + for j, role in enumerate(roles): + x = left + task_col_w + j * role_col_w + add_rect(slide, x, top, role_col_w, header_h, + self.theme["colors"]["primary"]["dark_blue"]) + add_text_box(slide, x, top + 0.10, role_col_w, 0.45, + role, self._font_body, 9, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + + for i, task in enumerate(tasks): + y = top + header_h + i * row_h + bg = ("#ffffff" if i % 2 == 0 + else self.theme["colors"]["backgrounds"]["content_area"]) + + add_rect(slide, left, y, task_col_w, row_h, bg) + add_text_box(slide, left + 0.20, y + 0.12, + task_col_w - 0.30, row_h - 0.15, + task.get("label", ""), + self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + raci_vals = task.get("raci", []) + for j in range(len(roles)): + x = left + task_col_w + j * role_col_w + add_rect(slide, x, y, role_col_w, row_h, bg) + + val = raci_vals[j] if j < len(raci_vals) else "" + if val in raci_colors: + r_d = 0.35 + r_x = x + role_col_w / 2 - r_d / 2 + r_y = y + row_h / 2 - r_d / 2 + opacity = 1.0 if val in ("R", "A", "C") else 0.45 + shape = slide.shapes.add_shape(9, + cm(r_x), cm(r_y), cm(r_d), cm(r_d)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(raci_colors[val]) + shape.line.fill.background() + + add_text_box(slide, r_x, r_y + 0.04, + r_d, r_d - 0.08, + val, self._font_body, 8, + bold=True, + color="#ffffff" if val in ("R", "A") else "#000a32", + align=PP_ALIGN.CENTER) + + add_line(slide, left, y + row_h, left + width, y + row_h, "#e8e2d6", 0.02) + + # C25 — decision_node ────────────────────────────────────────────────── + + def _render_decision_tree(self, slide, zone, slide_data, + left, top, width, height): + """Arbre de décision YES/NO.""" + question = slide_data.get("question", "") + branches = slide_data.get("branches", {}) + + # Question centrale + q_w, q_h = 7.0, 2.80 + q_x = left + 0.50 + q_y = top + height / 2 - q_h / 2 + + add_rect(slide, q_x, q_y, q_w, q_h, + self.theme["colors"]["backgrounds"]["content_area"], + "#48545a", 0.03) + add_text_box(slide, q_x + 0.30, q_y + 0.30, + q_w - 0.60, q_h - 0.60, + question, self._font_body, 10, + color=self.theme["colors"]["primary"]["dark_blue"]) + + branch_configs = [ + ("yes", "YES", top + height * 0.20), + ("no", "NO", top + height * 0.65), + ] + colors_branch = { + "yes": self.theme["colors"]["primary"]["bright_warm"], + "no": self.theme["colors"]["primary"]["rose"], + } + + for key, label, branch_y in branch_configs: + branch = branches.get(key, {}) + if not branch: + continue + + # Connecteur + label YES/NO + add_line(slide, q_x + q_w, q_y + q_h / 2, + left + q_w + 2.0, branch_y + 1.0, + "#48545a", 0.03) + add_text_box(slide, q_x + q_w + 0.20, + (q_y + q_h / 2 + branch_y + 1.0) / 2 - 0.15, + 0.80, 0.30, label, + self._font_body, 8, bold=True, + color="#48545a") + + # Nœud branche + b_x = left + q_w + 2.0 + b_w, b_h = 6.0, 2.20 + branch_color = colors_branch.get(key, "#d9d9c4") + add_rect(slide, b_x, branch_y, b_w, b_h, + branch_color, "#48545a", 0.03) + add_text_box(slide, b_x + 0.25, branch_y + 0.25, + b_w - 0.50, b_h - 0.50, + branch.get("label", ""), + self._font_body, 10, + color=self.theme["colors"]["primary"]["dark_blue"]) + + # Options terminales + options = branch.get("options", []) + opt_x = b_x + b_w + 0.80 + opt_w = left + width - opt_x - 0.20 + for k, opt in enumerate(options[:2]): + opt_y = branch_y + k * 1.20 + add_line(slide, b_x + b_w, branch_y + b_h / 2, + opt_x, opt_y + 0.40, "#48545a", 0.02) + add_rect(slide, opt_x, opt_y, opt_w, 1.0, + self.theme["colors"]["backgrounds"]["content_area"], + "#e8e2d6", 0.02) + add_text_box(slide, opt_x + 0.20, opt_y + 0.15, + opt_w - 0.40, 0.70, + opt, self._font_body, 9, + color=self.theme["colors"]["primary"]["dark_blue"]) + + # C26 — recommendation_sidebar ───────────────────────────────────────── + + def _render_recommendation_sidebar(self, slide, zone, slide_data, + left, top, width, height): + """Sidebar jaune + corps de la recommandation.""" + numero = slide_data.get("numero", 1) + titre = slide_data.get("titre", "") + resume = slide_data.get("resume", "") + cta = slide_data.get("cta", "") + headline = slide_data.get("headline", "") + bullets = slide_data.get("bullets", []) + + # Sidebar fond jaune + sidebar_w = width # width = 8.0 cm (défini dans layouts.yaml) + add_rect(slide, left, top, sidebar_w, height, + self.theme["colors"]["backgrounds"]["highlight_box"]) + + # Cercle numéro + r = 0.55 + cx = left + sidebar_w / 2 + shape = slide.shapes.add_shape(9, + cm(cx - r), cm(top + 1.0), cm(r * 2), cm(r * 2)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb( + self.theme["colors"]["primary"]["dark_blue"]) + shape.line.fill.background() + add_text_box(slide, cx - r, top + 1.0, r * 2, r * 2, + str(numero), self._font_display, 18, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + + # Titre sidebar + add_text_box(slide, left + 0.30, top + 2.30, + sidebar_w - 0.60, 1.50, + titre, self._font_display, 16, + bold=True, + color=self.theme["colors"]["primary"]["dark_blue"], + align=PP_ALIGN.CENTER) + + # Résumé + if resume: + add_text_box(slide, left + 0.30, top + 4.0, + sidebar_w - 0.60, 3.0, + resume, self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # CTA + if cta: + add_rect(slide, left + 0.30, top + height - 2.0, + sidebar_w - 0.60, 0.80, + self.theme["colors"]["primary"]["dark_blue"]) + add_text_box(slide, left + 0.30, top + height - 2.0, + sidebar_w - 0.60, 0.80, + cta, self._font_body, 9, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + + # Contenu principal à droite de la sidebar + content_left = left + sidebar_w + 0.50 + content_w = 33.87 - content_left - 0.50 + + # Header band + if headline: + add_rect(slide, content_left, top + 0.80, + content_w, 1.10, + self.theme["colors"]["primary"]["dark_blue"]) + add_text_box(slide, content_left + 0.30, top + 1.0, + content_w - 0.60, 0.70, + headline, self._font_body, 11, + bold=True, color="#ffffff") + + # Bullets + if bullets: + zone_fake = {"id": "main_content", "component": "C06", + "width_cm": content_w} + slide_fake = {"bullets": bullets} + self._render_bullet_list(slide, zone_fake, slide_fake, + content_left, top + 2.20, + content_w, height - 2.50) + + +# ───────────────────────────────────────────────────────────────────────────── +# CLI +# ───────────────────────────────────────────────────────────────────────────── + +def main(): + import argparse + parser = argparse.ArgumentParser( + description="Sliding render_engine — JSON → PPTX Pernod Ricard") + parser.add_argument("json_file", + help="Fichier JSON de la présentation (sortie Agent 3)") + parser.add_argument("output", + help="Chemin du fichier PPTX à générer") + parser.add_argument("--theme", + default="theme.yaml", + help="Chemin vers theme.yaml (défaut: ./theme.yaml)") + parser.add_argument("--components", + default="components.yaml", + help="Chemin vers components.yaml") + parser.add_argument("--layouts", + default="layouts.yaml", + help="Chemin vers layouts.yaml") + args = parser.parse_args() + + if not os.path.exists(args.json_file): + print(f"✗ Fichier JSON introuvable : {args.json_file}") + sys.exit(1) + for f in [args.theme, args.components, args.layouts]: + if not os.path.exists(f): + print(f"✗ Fichier YAML introuvable : {f}") + sys.exit(1) + + engine = RenderEngine(args.theme, args.components, args.layouts) + with open(args.json_file, encoding="utf-8") as f: + raw = f.read().strip() + if not raw: + print(f"\u2717 Fichier d\'entr\u00e9e vide : {args.json_file}") + sys.exit(1) + # Accepte YAML ou JSON indiff\u00e9remment + try: + import yaml as _yaml + json_data = _yaml.safe_load(raw) + except Exception: + json_data = json.loads(raw) + engine.render(json_data, args.output) + + +if __name__ == "__main__": + main() diff --git a/archive/v1_pipeline/agent_constraints.md b/archive/v1_pipeline/agent_constraints.md new file mode 100644 index 0000000..6865ab2 --- /dev/null +++ b/archive/v1_pipeline/agent_constraints.md @@ -0,0 +1,740 @@ +# agent_constraints.md +# Généré automatiquement par prompt_injection.py +# Ne pas modifier manuellement. + +====================================================================== +## SECTION A — CATALOGUE DES LAYOUTS (pour The Designer) +====================================================================== + +#### Couverture & navigation + +`cover_split` (L01) — Rôles : accroche + → Réservé au slide 1. Titre fort et accrocheur, sous-titre contextualisé. Pas de bullets. Message = raison d'être de la... + +`section_divider` (L02) — Rôles : transition + → Slide de transition entre parties. Titre = nom de la section, percutant. Numéro de section bien visible. Pas de conte... + +`agenda` (L03) — Rôles : transition / contexte + → Sommaire de la présentation. Items = titres des sections, pas des bullets de contenu. Présenter l'ossature narrative,... + +`content_marker` (L04) — Rôles : transition + → Rappel de l'agenda en cours de présentation. current_index indique l'item actif (en orange). Identique à agenda en st... + +`end_slide` (L05) — Rôles : conclusion / next-steps + → Dernier slide obligatoire. Titre = message de clôture fort. Next steps = 2-4 actions concrètes. Contact optionnel. + +#### Texte structuré + +`default_bullets` (L06) — Rôles : contexte / solution / preuve + → Layout texte par défaut. Titre = So What affirmatif. Bullets L1 = arguments principaux, L2 = preuves/détails, L3 = ex... + +`two_cols_text` (L07) — Rôles : contexte / comparaison / solution + → Deux colonnes de texte en parallèle. Idéal pour mise en contraste : Objectifs / Approche, Problème / Solution, Avant ... + +`key_message` (L08) — Rôles : accroche / solution / conclusion + → Un seul message clé, occupant presque tout le slide. Style quote. Fond crème, guillemets décoratifs. Pas de bullets. ... + +`executive_summary` (L09) — Rôles : contexte / solution + → Synthèse SCR en 3 blocs. Situation = état des lieux factuel. Complication = le problème ou la tension. Résolution = l... + +#### Données & KPIs + +`kpi_grid` (L10) — Rôles : preuve / contexte + → Grille de KPIs. Chaque carte = 1 indicateur avec sa valeur et son contexte. La grille s'adapte automatiquement selon ... + +`big_stat` (L11) — Rôles : preuve / accroche + → Un seul chiffre énorme, centré, pour un effet de choc. Utiliser pour un indicateur décisif qui mérite d'être seul sur... + +`comparison_table` (L12) — Rôles : preuve / comparaison + → Tableau comparatif structuré. Header = noms des critères ou acteurs. Rows = lignes de données. Max 6 colonnes × 8 lig... + +`chart_callout` (L13) — Rôles : preuve + → Graphique à gauche + encadré d'insight à droite. Le chart illustre, le callout conclut. Suivre la règle BCG : le mess... + +`benchmark` (L14) — Rôles : contexte / comparaison + → Benchmark concurrents. Critères en lignes, acteurs en colonnes de barres. Max 4 acteurs. Utiliser pour positionner PR... + +#### Frameworks visuels + +`matrix_2x2` (L15) — Rôles : contexte / solution + → Matrice 2×2 pour priorisation (impact/effort, urgence/importance…). Chaque item = une bulle positionnée par coordonné... + +`pyramid` (L16) — Rôles : contexte / solution + → Pyramide hiérarchique. Niveau 1 = sommet (plus petit, plus rare/premium). Niveau N = base (plus large, plus fondament... + +`circular_diagram` (L17) — Rôles : contexte / solution + → Diagramme circulaire avec logo PR central. Idéal pour écosystème, dimensions complémentaires, cycle vertueux. Légende... + +`from_to` (L18) — Rôles : solution / contexte + → Transformation FROM → TO. Chaque paire = un changement concret. Bloc gauche optionnel pour contexte. Bloc jaune en ov... + +`boxes_grid` (L19) — Rôles : preuve / contexte + → Matrice analytique dense. Colonne label (wheat yellow) + colonne KPI + 3-4 colonnes de contenu. Pour analyse multi-cr... + +#### Process & roadmap + +`numbered_steps` (L20) — Rôles : solution / methodologie + → Étapes numérotées verticalement. Idéal pour méthodologie, bonnes pratiques, checklist d'implémentation. Titre de step... + +`process_arrow` (L21) — Rôles : solution / next-steps + → Chevrons horizontaux pour une roadmap phasée. Étape active = dark blue. Dernier chevron = triangle fermé (→). Bullets... + +`gantt_timeline` (L22) — Rôles : next-steps / solution + → Gantt simplifié. Workstreams à gauche (optionnel), barres colorées sur la timeline. Une couleur par workstream. Dates... + +`yearly_timeline` (L23) — Rôles : contexte + → Timeline horizontale de jalons annuels. Idéal pour historique ou prospective. Jalon actif en rose. Labels années au-d... + +`phases_timeline` (L24) — Rôles : solution / methodologie + → Timeline en phases horizontales contiguës. Largeur proportionnelle à la durée. Chaque phase = un bandeau coloré + pér... + +#### Acteurs & décision + +`org_chart` (L25) — Rôles : acteurs / contexte + → Organigramme hiérarchique. Niveau 1 = leadership (dark blue). Nœuds distribués automatiquement. Max 4 niveaux, max 20... + +`raci_table` (L26) — Rôles : acteurs / solution + → Matrice RACI. Tâches en lignes, acteurs en colonnes header (R/A/C/I). R = Responsible (fait), A = Accountable (décide... + +`decision_tree` (L27) — Rôles : decision / solution + → Arbre de décision binaire. Question centrale → 2 branches YES/NO → 2 options par branche. Branche recommandée en rose... + +`recommendation_card` (L28) — Rôles : solution / decision / next-steps + → Carte de recommandation unique et cadrée. Sidebar gauche jaune = numéro + titre court + résumé + CTA. Corps droit = h... + +====================================================================== +## SECTION B — RÈGLES DE SÉQUENÇAGE (pour The Designer) +====================================================================== +### Règles de fluidité + +- Maximum 2 layouts texte consécutifs : `default_bullets`, `two_cols_text` +- Maximum 2 tableaux consécutifs : `comparison_table`, `benchmark`, `raci_table`, `boxes_grid` +- Après 3 slides denses → intercaler une respiration : `big_stat`, `key_message`, `section_divider` +- Jamais 2 `section_divider` consécutifs +- Le dernier slide de contenu avant `end_slide` doit être narratif (pas un tableau, pas un gantt) + +### Règles de choix de layout + +- 1 seul chiffre décisif → `big_stat` (jamais `kpi_grid` avec 1 item) +- 2 à 6 indicateurs chiffrés → `kpi_grid` +- Transformation conceptuelle (avant/après) → `from_to` (pas `two_cols_text`) +- Plus de 4 étapes avec timing → `process_arrow` ou `phases_timeline` (pas `numbered_steps`) +- Recommandation unique et précise → `recommendation_card` +- Données à comparer sur plusieurs critères avec plusieurs acteurs → `benchmark` +- Données à comparer en tableau structuré → `comparison_table` +- Si le contenu ne rentre dans aucun layout spécialisé → `default_bullets` +- Citation ou message à marteler seul → `key_message` +- Organigramme de gouvernance → `org_chart` +- Responsabilités par rôle → `raci_table` + +### Règles de quantité + +- Présentation 20 min → 10 à 15 slides max +- Présentation 10 min → 6 à 10 slides max +- Fusionner si > 15 slides : regrouper les slides proches thématiquement +- Un `###` du Markdown avec un seul chiffre fort → envisager `big_stat` séparé +- Un `##` du Markdown = 1 `section_divider` (sauf présentation < 6 slides) + +====================================================================== +## SECTION C — PATTERNS NARRATIFS (pour The Designer) +====================================================================== +### Pattern "Problem → Solution → Proof" +Adapté aux présentations de recommandation stratégique. +``` +cover_split +executive_summary (synthèse SCR dès le début) +section_divider ("Le problème") +big_stat (chiffre choc) +default_bullets ou comparison_table +section_divider ("Notre réponse") +from_to ou numbered_steps +kpi_grid ou chart_callout (preuve que ça marche) +recommendation_card (ce qu'on demande) +end_slide +``` + +### Pattern "Roadmap Deck" +Adapté aux présentations de planification / lancement de projet. +``` +cover_split +executive_summary (où on va et pourquoi) +kpi_grid (état des lieux chiffré) +phases_timeline ou gantt_timeline +numbered_steps (comment on s'organise) +org_chart ou raci_table (qui fait quoi) +recommendation_card (décisions à prendre) +end_slide +``` + +### Pattern "Data Storytelling" +Adapté aux présentations de revue de performance ou data governance. +``` +cover_split +big_stat (chiffre choc d'entrée) +default_bullets (contexte et enjeux) +kpi_grid (panorama des indicateurs) +chart_callout (analyse d'un graphique clé) +from_to (implication / transformation attendue) +yearly_timeline (historique ou prospective) +end_slide +``` + +### Pattern "Executive Briefing" +Adapté aux présentations courtes (< 10 slides) pour un CODIR. +``` +cover_split +executive_summary +key_message (le So What en 1 slide) +kpi_grid ou big_stat +recommendation_card +end_slide +``` + +### Règles d'assemblage des patterns + +- Les patterns sont des points de départ, pas des contraintes rigides +- Hybrider 2 patterns est possible si le contenu le justifie +- Toujours préserver : cover_split en premier, end_slide en dernier +- Les section_dividers sont optionnels pour les patterns courts (< 8 slides) + +====================================================================== +## SECTION D — SCHÉMAS YAML (pour The Encoder) +====================================================================== + +### `cover_split` (L01) +**Requis :** titre +**Optionnels :** sous_titre, accroche +**Contraintes :** + titre : max 70 caractères + sous_titre : max 80 caractères +**Exemple minimal :** +```yaml +layout: cover_split +titre: "Votre titre affirmatif" +sous_titre: "Présentation au CODIR — juin 2026" +``` + +### `section_divider` (L02) +**Requis :** titre, numero_section +**Optionnels :** image +**Contraintes :** + titre : max 60 caractères +**Exemple minimal :** +```yaml +layout: section_divider +titre: "Votre titre affirmatif" +numero_section: 1 +``` + +### `agenda` (L03) +**Requis :** titre, items +**Contraintes :** + items : min = 2 + items : max = 6 + item_schema : requis: numero, titre | optionnels: presentateur +**Exemple minimal :** +```yaml +layout: agenda +titre: "Votre titre affirmatif" +items: + - numero: 1 + titre: "Contexte et enjeux" + - numero: 2 + titre: "Notre proposition" +``` + +### `content_marker` (L04) +**Requis :** items, current_index +**Contraintes :** + items : min = 2 + items : max = 6 +**Exemple minimal :** +```yaml +layout: content_marker +titre: "Votre titre affirmatif" +current_index: 2 +``` + +### `end_slide` (L05) +**Requis :** titre +**Optionnels :** sous_titre, message, next_steps, contacts +**Contraintes :** + titre : max 70 caractères + next_steps : max = 4 +**Exemple minimal :** +```yaml +layout: end_slide +titre: "Votre titre affirmatif" +message: "Merci pour votre attention" +next_steps: + - texte: "Valider le modèle — juillet" + niveau: 1 +``` + +### `default_bullets` (L06) +**Requis :** titre, bullets +**Optionnels :** sous_titre +**Contraintes :** + bullets : min = 1 + bullets : max = 10 + bullet_l1 : max = 5 + bullet : max 120 caractères +**Exemple minimal :** +```yaml +layout: default_bullets +titre: "Votre titre affirmatif" +bullets: + - texte: "Premier argument clé" + niveau: 1 + - texte: "Détail ou preuve" + niveau: 2 +``` + +### `two_cols_text` (L07) +**Requis :** titre, left, right +**Optionnels :** sous_titre +**Contraintes :** + left_schema : requis: titre, contenu + right_schema : requis: titre, contenu +**Exemple minimal :** +```yaml +layout: two_cols_text +titre: "Votre titre affirmatif" +left: + titre: "Titre colonne gauche" + contenu: "Texte de la colonne gauche..." +right: + titre: "Titre colonne droite" + contenu: "Texte de la colonne droite..." +``` + +### `key_message` (L08) +**Requis :** message +**Optionnels :** auteur, fonction +**Contraintes :** + message : max 220 caractères + auteur : max 60 caractères +**Exemple minimal :** +```yaml +layout: key_message +titre: "Votre titre affirmatif" +message: "Le message clé en une phrase forte." +``` + +### `executive_summary` (L09) +**Requis :** titre, situation, complication, resolution +**Optionnels :** sous_titre +**Contraintes :** + situation : max 300 caractères + complication : max 300 caractères + resolution : max 300 caractères +**Exemple minimal :** +```yaml +layout: executive_summary +titre: "Votre titre affirmatif" +situation: "État des lieux factuel..." +complication: "Le problème ou la tension..." +resolution: "La réponse proposée..." +``` + +### `kpi_grid` (L10) +**Requis :** titre, items +**Optionnels :** sous_titre +**Contraintes :** + items : min = 2 + items : max = 6 + item_schema : requis: titre, valeur | optionnels: sous_titre, couleur +**Exemple minimal :** +```yaml +layout: kpi_grid +titre: "Votre titre affirmatif" +items: + - titre: "Indicateur 1" + valeur: "85%" + sous_titre: "Contexte de la valeur" + - titre: "Indicateur 2" + valeur: "+25%" +``` + +### `big_stat` (L11) +**Requis :** titre, valeur +**Optionnels :** sous_titre, label, source +**Contraintes :** + valeur : max 10 caractères + label : max 80 caractères + source : max 60 caractères +**Exemple minimal :** +```yaml +layout: big_stat +titre: "Votre titre affirmatif" +valeur: "2 400" +label: "jours/homme de réconciliation par an" +source: "Estimation interne 2026" +``` + +### `comparison_table` (L12) +**Requis :** titre, headers, rows +**Optionnels :** sous_titre, col_widths, highlight_col +**Contraintes :** + headers : min = 2 + headers : max = 6 + rows : min = 1 + rows : max = 8 + header : max 30 caractères + cell : max 60 caractères +**Exemple minimal :** +```yaml +layout: comparison_table +titre: "Votre titre affirmatif" +headers: + - "Critère" + - "Option A" + - "Option B" +rows: + - ["Coût", "Élevé", "Moyen"] + - ["Délai", "3 mois", "6 mois"] +``` + +### `chart_callout` (L13) +**Requis :** titre, chart_type, data, insight +**Optionnels :** sous_titre, axis_x_label, axis_y_label, couleurs, titre_insight +**Contraintes :** + data : min = 2 + data : max = 8 + insight : max 200 caractères +**Exemple minimal :** +```yaml +layout: chart_callout +titre: "Votre titre affirmatif" +chart_type: bar +data: + - label: "T1" + valeur: 40 + - label: "T2" + valeur: 65 +insight: "La croissance s'accélère au T2 grâce au pilote." +``` + +### `benchmark` (L14) +**Requis :** titre, criteria, actors, scores +**Optionnels :** sous_titre, couleurs_acteurs +**Contraintes :** + criteria : min = 2 + criteria : max = 6 + actors : min = 2 + actors : max = 4 + critere : max 40 caractères + actor : max 20 caractères +**Exemple minimal :** +```yaml +layout: benchmark +titre: "Votre titre affirmatif" +criteria: + - "Coût" + - "Délai" +actors: + - "PR" + - "Concurrent A" +scores: + - [80, 60] + - [70, 85] +``` + +### `matrix_2x2` (L15) +**Requis :** titre, axis_x, axis_y, items +**Optionnels :** sous_titre +**Contraintes :** + axis_schema : requis: label | optionnels: min_label, max_label + items : min = 2 + items : max = 8 + item_schema : requis: label, x, y | optionnels: taille, couleur +**Exemple minimal :** +```yaml +layout: matrix_2x2 +titre: "Votre titre affirmatif" +axis_x: + label: "Effort" +axis_y: + label: "Impact" +items: + - label: "Initiative A" + x: 20 + y: 80 + taille: 3 +``` + +### `pyramid` (L16) +**Requis :** titre, levels +**Optionnels :** sous_titre +**Contraintes :** + levels : min = 3 + levels : max = 5 + level_schema : requis: label | optionnels: description, couleur +**Exemple minimal :** +```yaml +layout: pyramid +titre: "Votre titre affirmatif" +levels: + - label: "Vision" + description: "Callout explicatif optionnel" + - label: "Stratégie" + - label: "Opérations" +``` + +### `circular_diagram` (L17) +**Requis :** titre, segments +**Optionnels :** sous_titre +**Contraintes :** + segments : min = 3 + segments : max = 6 + segment_schema : requis: label, description | optionnels: couleur, poids +**Exemple minimal :** +```yaml +layout: circular_diagram +titre: "Votre titre affirmatif" +segments: + - label: "Segment 1" + description: "Description courte" + - label: "Segment 2" + description: "Description courte" + - label: "Segment 3" + description: "Description courte" +``` + +### `from_to` (L18) +**Requis :** titre, pairs +**Optionnels :** sous_titre, description, titre_summary, summary +**Contraintes :** + pairs : min = 2 + pairs : max = 5 + pair_schema : requis: from, to + description : max 200 caractères + summary : max 150 caractères +**Exemple minimal :** +```yaml +layout: from_to +titre: "Votre titre affirmatif" +pairs: + - from: "Situation actuelle" + to: "Situation cible" + - from: "Processus manuel" + to: "Processus automatisé" +``` + +### `boxes_grid` (L19) +**Requis :** titre, columns, rows +**Optionnels :** sous_titre +**Contraintes :** + columns : min = 3 + columns : max = 5 + rows : min = 2 + rows : max = 5 + column : max 25 caractères + row_schema : requis: label, kpi, contents +**Exemple minimal :** +```yaml +layout: boxes_grid +titre: "Votre titre affirmatif" +columns: + - "Colonne 1" + - "Colonne 2" +rows: + - label: "Ligne A" + kpi: "xx%" + contents: ["Contenu 1", "Contenu 2"] +``` + +### `numbered_steps` (L20) +**Requis :** titre, steps +**Optionnels :** sous_titre +**Contraintes :** + steps : min = 2 + steps : max = 6 + step_schema : requis: numero, titre | optionnels: description +**Exemple minimal :** +```yaml +layout: numbered_steps +titre: "Votre titre affirmatif" +steps: + - numero: 1 + titre: "Première étape" + description: "Ce que ça implique concrètement" + - numero: 2 + titre: "Deuxième étape" +``` + +### `process_arrow` (L21) +**Requis :** titre, phases +**Optionnels :** sous_titre +**Contraintes :** + phases : min = 2 + phases : max = 5 + phase_schema : requis: label | optionnels: duree, actif, bullets, terminal +**Exemple minimal :** +```yaml +layout: process_arrow +titre: "Votre titre affirmatif" +phases: + - label: "Phase 1" + duree: "Juin" + actif: false + bullets: ["Livrable A", "Livrable B"] + - label: "Phase 2" + duree: "Juil-Sept" + actif: true +``` + +### `gantt_timeline` (L22) +**Requis :** titre, period, workstreams +**Optionnels :** sous_titre +**Contraintes :** + period_schema : requis: start, end + workstreams : min = 1 + workstreams : max = 5 + workstream_schema : requis: tasks | optionnels: label +**Exemple minimal :** +```yaml +layout: gantt_timeline +titre: "Votre titre affirmatif" +period: + start: "2026-06" + end: "2026-12" +workstreams: + - label: "Workstream 1" + tasks: + - start: "2026-06" + end: "2026-08" +``` + +### `yearly_timeline` (L23) +**Requis :** titre, milestones +**Optionnels :** sous_titre +**Contraintes :** + milestones : min = 3 + milestones : max = 6 + milestone_schema : requis: annee, label | optionnels: description, actif +**Exemple minimal :** +```yaml +layout: yearly_timeline +titre: "Votre titre affirmatif" +milestones: + - annee: "2024" + label: "Lancement du projet" + - annee: "2025" + label: "Pilote Suède" + actif: true + - annee: "2026" + label: "Déploiement nordique" +``` + +### `phases_timeline` (L24) +**Requis :** titre, phases +**Optionnels :** sous_titre +**Contraintes :** + phases : min = 2 + phases : max = 5 + phase_schema : requis: label, periode | optionnels: items +**Exemple minimal :** +```yaml +layout: phases_timeline +titre: "Votre titre affirmatif" +phases: + - label: "PREP" + periode: "Juin" + items: ["Brief équipe", "Setup outil"] + - label: "PROD" + periode: "Juil-Oct" + items: ["Développement", "Tests"] +``` + +### `org_chart` (L25) +**Requis :** titre, root +**Optionnels :** sous_titre +**Contraintes :** + node_schema : requis: label | optionnels: sous_label, children +**Exemple minimal :** +```yaml +layout: org_chart +titre: "Votre titre affirmatif" +root: + label: "Data Gov Leader" + children: + - label: "Data Owner Finance" + children: + - label: "Data Steward" + - label: "Data Owner Supply" +``` + +### `raci_table` (L26) +**Requis :** titre, roles, tasks +**Optionnels :** sous_titre +**Contraintes :** + roles : min = 3 + roles : max = 6 + tasks : min = 2 + tasks : max = 8 + role : max 25 caractères + task_schema : requis: label, raci +**Exemple minimal :** +```yaml +layout: raci_table +titre: "Votre titre affirmatif" +roles: + - "Data Owner" + - "Data Steward" + - "IT" +tasks: + - label: "Définir les règles qualité" + raci: ["A", "R", "C"] + - label: "Exécuter les contrôles" + raci: ["A", "R", "I"] +``` + +### `decision_tree` (L27) +**Requis :** titre, question, branches +**Optionnels :** sous_titre +**Contraintes :** + question : max 80 caractères + branches_schema : requis: True, False +**Exemple minimal :** +```yaml +layout: decision_tree +titre: "Votre titre affirmatif" +question: "Faut-il déployer le pilote en Suède ?" +branches: + yes: + label: "Engagement DG confirmé" + options: ["Démarrer en juin", "Allouer 0.5 ETP"] + no: + label: "Engagement DG manquant" + options: ["Reporter à septembre", "Choisir une autre filiale"] +``` + +### `recommendation_card` (L28) +**Requis :** numero, titre, headline, bullets +**Optionnels :** subtitle, resume, cta +**Contraintes :** + numero : min = 1 + numero : max = 9 + titre : max 30 caractères + headline : max 40 caractères + bullets : min = 2 + bullets : max = 6 + bullet : max 100 caractères + resume : max 120 caractères + cta : max 30 caractères +**Exemple minimal :** +```yaml +layout: recommendation_card +titre: "Votre titre affirmatif" +numero: 1 + headline: "TROIS DÉCISIONS AVANT FIN JUIN" +bullets: + - texte: "Valider le modèle avec les DG locaux" + niveau: 1 + - texte: "Nommer les Data Owners" + niveau: 1 + - texte: "Allouer 0.5 ETP par filiale" + niveau: 1 +cta: "Décider en réunion du 30 juin" +``` \ No newline at end of file diff --git a/archive/v1_pipeline/components.yaml b/archive/v1_pipeline/components.yaml new file mode 100644 index 0000000..45c89f2 --- /dev/null +++ b/archive/v1_pipeline/components.yaml @@ -0,0 +1,1266 @@ +# ============================================================================= +# components.yaml — Composants atomiques Pernod Ricard Sliding +# Projet : Sliding Design System +# Usage : lu par render_engine.py pour assembler les layouts +# Source : PR Template officiel + galerie 28 layouts +# ============================================================================= +# PRINCIPE +# Chaque composant est une brique réutilisable positionnée par render_engine.py. +# Un layout (layouts.yaml) est un assemblage de composants + règles de placement. +# Un composant déclare : +# - ses champs JSON attendus (requis / optionnels) +# - ses contraintes (min/max items, longueurs de texte) +# - ses paramètres visuels (couleurs, tailles, positions relatives) +# - ses références theme.yaml +# ============================================================================= + +meta: + version: "1.0" + date: "2026-05-14" + total_components: 22 + categories: + - structure # titre, sous-titre, barre d'accent, footer, fond + - texte # bullets, paragraphe, citation, message clé + - donnees # kpi_card, big_stat, tableau, chart + - visuel # formes, diagrammes, pyramide, circulaire + - process # étapes, chevrons, gantt, timeline + - acteurs # orgchart node, raci cell, decision node + + +# ============================================================================= +# CATÉGORIE : STRUCTURE +# Composants présents sur (presque) tous les slides +# ============================================================================= + +components: + + # --------------------------------------------------------------------------- + # C01 — slide_background + # Fond coloré du slide. Toujours le premier composant instancié. + # --------------------------------------------------------------------------- + slide_background: + id: C01 + category: structure + description: "Fond plein ou dégradé du slide. Définit le contexte visuel global." + used_by_layouts: + - tous + fields: + required: + color: + type: string + description: "Couleur de fond — référencer theme.colors" + examples: ["#ffffff", "#000a32", "#d9d9c4"] + optional: + color_right: + type: string + description: "Couleur du panneau droit pour les splits diagonaux" + default: null + diagonal_split: + type: boolean + description: "Active la partition diagonale (cover, section_divider)" + default: false + diagonal_angle_deg: + type: integer + description: "Angle de la diagonale en degrés" + default: 15 + render: + type: rectangle_fill + position: full_slide + z_index: 0 + + + # --------------------------------------------------------------------------- + # C02 — slide_title + # Titre principal du slide. Présent sur tous les layouts sauf cover (traitement spécial). + # --------------------------------------------------------------------------- + slide_title: + id: C02 + category: structure + description: "Titre action du slide (formulation So What). 2 lignes max." + used_by_layouts: + - default_bullets + - two_cols_text + - key_message + - executive_summary + - kpi_grid + - big_stat + - comparison_table + - chart_callout + - benchmark + - matrix_2x2 + - pyramid + - circular_diagram + - from_to + - boxes_grid + - numbered_steps + - process_arrow + - gantt_timeline + - yearly_timeline + - phases_timeline + - org_chart + - raci_table + - decision_tree + - recommendation_card + fields: + required: + text: + type: string + max_chars: 80 + description: "Titre du slide — formulation affirmative, jamais thématique" + optional: + subtitle: + type: string + max_chars: 60 + description: "Sous-titre ou date" + default: null + constraints: + max_lines: 2 + max_chars_per_line: 45 + render: + font: "theme.typography.display" + size_pt: "theme.typography.sizes.slide_title" + color_on_white: "theme.colors.text.on_white" + color_on_dark: "theme.colors.text.on_dark" + position_left_cm: 1.8 # décalé pour laisser place à la barre d'accent + position_top_cm: 0.5 + width_cm: 27.0 + bold: true + subtitle_font: "theme.typography.body" + subtitle_size_pt: "theme.typography.sizes.slide_subtitle" + subtitle_color: "theme.colors.text.subtitle" + subtitle_margin_top_cm: 0.15 + z_index: 2 + + + # --------------------------------------------------------------------------- + # C03 — accent_bar + # Barre verticale rose à gauche du titre. Signature PR. + # --------------------------------------------------------------------------- + accent_bar: + id: C03 + category: structure + description: "Barre verticale rose, signature graphique PR. Alignée à gauche du titre." + used_by_layouts: "voir theme.signature.accent_bar.visible_on" + fields: {} # aucun champ JSON — paramètres fixes dans theme.yaml + render: + color: "theme.colors.primary.rose" + width_cm: "theme.signature.accent_bar.width_cm" + position_left_cm: "theme.signature.accent_bar.position_left_cm" + # hauteur = hauteur du titre (calculée dynamiquement) + height_dynamic: true + z_index: 2 + + + # --------------------------------------------------------------------------- + # C04 — pr_footer + # Footer bas de slide : numéro, séparateur, logo, "Pernod Ricard", tagline. + # --------------------------------------------------------------------------- + pr_footer: + id: C04 + category: structure + description: "Footer standard PR. Numéro de slide + logo + DATA GOVERNANCE DATA MANAGEMENT." + fields: + auto: + slide_number: + type: integer + description: "Injecté automatiquement par render_engine.py" + render: + height_cm: "theme.signature.footer.height_cm" + position_bottom: true + background: "theme.signature.footer.background" + border_top_color: "theme.signature.footer.border_top_color" + elements: "theme.signature.footer.elements" + z_index: 10 + + + # --------------------------------------------------------------------------- + # C05 — pr_logo_topbar + # Logo soleil Pernod Ricard, top-left, slides à fond clair. + # --------------------------------------------------------------------------- + pr_logo_topbar: + id: C05 + category: structure + description: "Logo PR (icône soleil) top-left. Slides fond blanc/clair uniquement." + fields: {} + render: + file: "theme.assets.logo_sun" + position_left_cm: "theme.signature.logo_topbar.position_left_cm" + position_top_cm: "theme.signature.logo_topbar.position_top_cm" + height_cm: "theme.signature.logo_topbar.height_cm" + z_index: 3 + + +# ============================================================================= +# CATÉGORIE : TEXTE +# ============================================================================= + + # --------------------------------------------------------------------------- + # C06 — bullet_list + # Liste à puces hiérarchisée, 3 niveaux. Composant texte le plus fréquent. + # --------------------------------------------------------------------------- + bullet_list: + id: C06 + category: texte + description: "Liste à puces 1 à 3 niveaux. Bullet L1 gros, L2/L3 réduits et indentés." + used_by_layouts: + - default_bullets + - two_cols_text + - process_arrow # sous les chevrons + fields: + required: + items: + type: array + min_items: 1 + max_items: 10 + item_schema: + texte: + type: string + max_chars: 120 + niveau: + type: integer + values: [1, 2, 3] + sous_items: + type: array + optional: true + max_items: 4 + constraints: + max_total_lines: 14 # au-delà, render_engine tronque avec "…" + max_chars_l1: 100 + max_chars_l2: 90 + max_chars_l3: 80 + render: + l1: + marker: "theme.bullets.level_1.marker" + indent_cm: "theme.bullets.level_1.indent_cm" + font: "theme.typography.body" + size_pt: "theme.typography.sizes.bullet_l1" + color: "theme.colors.text.on_white" + space_before_pt: "theme.bullets.level_1.space_before_pt" + l2: + marker: "theme.bullets.level_2.marker" + indent_cm: "theme.bullets.level_2.indent_cm" + font: "theme.typography.body" + size_pt: "theme.typography.sizes.bullet_l2" + color: "theme.colors.text.body" + space_before_pt: "theme.bullets.level_2.space_before_pt" + l3: + marker: "theme.bullets.level_3.marker" + indent_cm: "theme.bullets.level_3.indent_cm" + font: "theme.typography.body" + size_pt: "theme.typography.sizes.bullet_l3" + color: "theme.colors.text.body" + space_before_pt: "theme.bullets.level_3.space_before_pt" + + + # --------------------------------------------------------------------------- + # C07 — text_paragraph + # Bloc de texte libre (non structuré en bullets). Usage : executive_summary, + # from_to description, org_chart légendes. + # --------------------------------------------------------------------------- + text_paragraph: + id: C07 + category: texte + description: "Bloc de texte libre. Titre de bloc optionnel en couleur accent." + used_by_layouts: + - executive_summary + - two_cols_text + - from_to + fields: + optional: + titre_bloc: + type: string + max_chars: 40 + description: "Titre de bloc (Situation / Complication / Résolution…)" + default: null + titre_couleur: + type: string + description: "Couleur du titre de bloc — hex ou référence theme" + default: "theme.colors.primary.dark_blue" + required: + contenu: + type: string + max_chars: 400 + constraints: + max_lines: 8 + render: + titre_font: "theme.typography.body" + titre_size_pt: "theme.typography.sizes.heading_1" + titre_bold: true + body_font: "theme.typography.body" + body_size_pt: "theme.typography.sizes.body" + body_color: "theme.colors.text.body" + line_spacing: "theme.typography.line_spacing.normal" + + + # --------------------------------------------------------------------------- + # C08 — quote_block + # Citation pleine slide. Fond crème, guillemet serif décoratif, attribution. + # --------------------------------------------------------------------------- + quote_block: + id: C08 + category: texte + description: "Citation isolée. Fond crème, guillemets Cormorant bleu, attribution discrète." + used_by_layouts: + - key_message + fields: + required: + citation: + type: string + max_chars: 220 + description: "Texte de la citation ou du message clé" + optional: + auteur: + type: string + max_chars: 60 + default: null + fonction: + type: string + max_chars: 60 + default: null + render: + background: "theme.colors.backgrounds.slide_warm" + guillemet_font: "theme.typography.display" + guillemet_size_pt: 72 + guillemet_color: "theme.colors.primary.bright_blue" + text_font: "theme.typography.display" + text_size_pt: 22 + text_color: "theme.colors.primary.dark_blue" + text_style: "normal" + attribution_font: "theme.typography.body" + attribution_size_pt: 10 + attribution_color: "theme.colors.text.body" + accent_bar_left: true # barre rose verticale à gauche du bloc + + +# ============================================================================= +# CATÉGORIE : DONNÉES +# ============================================================================= + + # --------------------------------------------------------------------------- + # C09 — kpi_card + # Carte KPI individuelle : header coloré + gros chiffre + label + sous-titre. + # Instanciée N fois par kpi_grid selon le nombre d'items. + # --------------------------------------------------------------------------- + kpi_card: + id: C09 + category: donnees + description: "Carte KPI unitaire. Header couleur cycle, valeur en rose, label, sous-titre." + used_by_layouts: + - kpi_grid + fields: + required: + titre: + type: string + max_chars: 30 + description: "Label de l'indicateur (header de la carte)" + valeur: + type: string + max_chars: 12 + description: "Valeur affichée en gros (ex: 78%, +25M€, -30%)" + optional: + sous_titre: + type: string + max_chars: 60 + description: "Contexte de la valeur" + default: null + couleur: + type: string + description: "Couleur du header — si null, utilise theme.colors.cycle" + default: null + render: + header: + height_cm: 0.6 + color: "cycle_auto" # tourne dans theme.colors.cycle si couleur=null + text_font: "theme.typography.body" + text_size_pt: "theme.typography.sizes.body_small" + text_color: "#ffffff" + text_bold: true + body: + background: "theme.colors.backgrounds.content_area" + valeur_font: "theme.typography.display" + valeur_size_pt: "theme.typography.sizes.kpi_value" + valeur_color: "theme.colors.primary.rose" + valeur_bold: true + label_font: "theme.typography.body" + label_size_pt: "theme.typography.sizes.body_small" + label_color: "theme.colors.text.body" + grid_rules: + # render_engine calcule position/taille de chaque carte selon le total + 2: { cols: 2, rows: 1 } + 3: { cols: 3, rows: 1 } + 4: { cols: 2, rows: 2 } + 5: { cols: 3, rows: 2 } # 5e carte centrée sur la 2e ligne + 6: { cols: 3, rows: 2 } + + + # --------------------------------------------------------------------------- + # C10 — big_stat_display + # Chiffre unique très grand, centré. Contexte sous le chiffre. + # --------------------------------------------------------------------------- + big_stat_display: + id: C10 + category: donnees + description: "Un seul chiffre en très grand format centré. Impact maximal." + used_by_layouts: + - big_stat + fields: + required: + valeur: + type: string + max_chars: 10 + description: "Chiffre à afficher (ex: 85%, 2.4Bn€, ×3)" + optional: + label: + type: string + max_chars: 80 + description: "Phrase de contexte sous le chiffre" + default: null + source: + type: string + max_chars: 60 + description: "Source de la donnée" + default: null + render: + valeur_font: "theme.typography.display" + valeur_size_pt: "theme.typography.sizes.big_stat" + valeur_color: "theme.colors.primary.rose" + valeur_bold: true + valeur_align: center + label_font: "theme.typography.body" + label_size_pt: "theme.typography.sizes.body" + label_color: "theme.colors.text.body" + label_align: center + source_size_pt: "theme.typography.sizes.body_small" + source_color: "theme.colors.text.caption" + vertical_center: true + + + # --------------------------------------------------------------------------- + # C11 — data_table + # Tableau structuré : header bleu foncé, lignes alternées clair/blanc. + # Usage : comparison_table, raci_table (en mode texte). + # --------------------------------------------------------------------------- + data_table: + id: C11 + category: donnees + description: "Tableau générique. Header dark blue, lignes alternées, bordures fines." + used_by_layouts: + - comparison_table + - benchmark + fields: + required: + headers: + type: array + min_items: 2 + max_items: 6 + item_type: string + max_chars_each: 30 + rows: + type: array + min_items: 1 + max_items: 8 + item_type: array # chaque row = liste de cellules + max_cols: 6 + max_chars_cell: 60 + optional: + col_widths: + type: array + description: "Largeurs relatives des colonnes (somme = 1.0)" + default: null # si null → répartition automatique égale + highlight_col: + type: integer + description: "Index de colonne à surligner (ex: notre offre)" + default: null + constraints: + max_cols: 6 + max_rows: 8 + render: + header: + background: "theme.colors.primary.dark_blue" + text_color: "#ffffff" + font: "theme.typography.body" + size_pt: "theme.typography.sizes.body_small" + bold: true + height_cm: 0.65 + rows: + odd_background: "#ffffff" + even_background: "theme.colors.backgrounds.content_area" + text_color: "theme.colors.text.body" + font: "theme.typography.body" + size_pt: "theme.typography.sizes.body_small" + row_height_cm: 0.55 + border_color: "theme.signature.footer.border_top_color" + border_width: 0.02 + highlight_col_background: "theme.colors.backgrounds.highlight_box" + + + # --------------------------------------------------------------------------- + # C12 — chart_placeholder + # Zone réservée pour un graphique (bar/line/pie). render_engine génère + # un graphique python-pptx ou insère une image si fournie. + # --------------------------------------------------------------------------- + chart_placeholder: + id: C12 + category: donnees + description: "Zone graphique. Bar chart par défaut. Accompagné d'un callout." + used_by_layouts: + - chart_callout + fields: + required: + chart_type: + type: string + values: [bar, line, pie, donut] + default: bar + data: + type: array + description: "Séries de données [{label, valeur}] ou [{label, serie1, serie2}]" + min_items: 2 + max_items: 8 + optional: + axis_x_label: + type: string + default: null + axis_y_label: + type: string + default: null + couleurs: + type: array + description: "Couleurs des séries — si null, utilise theme.colors.cycle" + default: null + render: + bar_color_default: "theme.colors.cycle" + bar_gap_pct: 20 + axis_font_size: "theme.typography.sizes.body_small" + axis_color: "theme.colors.text.body" + gridlines_color: "theme.signature.footer.border_top_color" + + + # --------------------------------------------------------------------------- + # C13 — callout_box + # Encadré d'insight (fond jaune wheat, bordure maize). Accompagne chart_placeholder. + # --------------------------------------------------------------------------- + callout_box: + id: C13 + category: donnees + description: "Boîte d'insight jaune. Titre en rose, corps en texte normal." + used_by_layouts: + - chart_callout + fields: + required: + insight: + type: string + max_chars: 200 + description: "Message clé de l'insight" + optional: + titre_insight: + type: string + max_chars: 40 + default: null + render: + background: "theme.colors.backgrounds.highlight_box" + border_color: "theme.colors.secondary.maize_yellow" + border_width: "theme.shapes.callout_box.border_width" + padding_cm: "theme.shapes.callout_box.padding_cm" + titre_font: "theme.typography.body" + titre_size_pt: "theme.typography.sizes.heading_2" + titre_color: "theme.colors.primary.rose" + titre_bold: true + body_font: "theme.typography.body" + body_size_pt: "theme.typography.sizes.body" + body_color: "theme.colors.text.body" + + +# ============================================================================= +# CATÉGORIE : VISUEL (frameworks) +# ============================================================================= + + # --------------------------------------------------------------------------- + # C14 — benchmark_bar + # Barre horizontale graduée pour un critère/acteur. Instanciée N×M fois. + # --------------------------------------------------------------------------- + benchmark_bar: + id: C14 + category: donnees + description: "Barre horizontale pour benchmark. Étiquette critère à gauche, barres acteurs." + used_by_layouts: + - benchmark + fields: + required: + critere: + type: string + max_chars: 40 + valeurs: + type: array + min_items: 2 + max_items: 4 + item_schema: + acteur: + type: string + max_chars: 20 + score: + type: number + min: 0 + max: 100 + optional: + couleurs_acteurs: + type: array + description: "Couleurs pour chaque acteur — si null, cycle auto" + default: null + constraints: + max_criteres: 6 + max_acteurs: 4 + render: + critere_width_cm: 4.0 + bar_height_cm: 0.35 + bar_gap_cm: 0.15 + bar_colors_default: + - "theme.colors.primary.bright_blue" + - "theme.colors.primary.rose" + - "theme.colors.secondary.barley_green" + - "theme.colors.secondary.maize_yellow" + critere_font_size: "theme.typography.sizes.body_small" + critere_color: "theme.colors.text.body" + + + # --------------------------------------------------------------------------- + # C15 — matrix_bubble + # Bulle positionnée sur une matrice 2×2. Instanciée N fois. + # --------------------------------------------------------------------------- + matrix_bubble: + id: C15 + category: visuel + description: "Bulle sur matrice. Position (x,y) normalisée [0-100]. Taille optionnelle." + used_by_layouts: + - matrix_2x2 + fields: + required: + label: + type: string + max_chars: 25 + x: + type: number + min: 0 + max: 100 + description: "Position axe X normalisée (0 = low, 100 = high)" + y: + type: number + min: 0 + max: 100 + description: "Position axe Y normalisée (0 = low, 100 = high)" + optional: + taille: + type: number + min: 1 + max: 5 + default: 2 + description: "Taille relative de la bulle (1=petite, 5=grande)" + couleur: + type: string + default: null + constraints: + max_bubbles: 8 + render: + bubble_min_diameter_cm: 0.5 + bubble_max_diameter_cm: 1.4 + bubble_opacity: 0.75 + label_font_size: "theme.typography.sizes.body_small" + label_color: "theme.colors.primary.dark_blue" + axis_color: "theme.colors.primary.dark_blue" + axis_stroke: 0.06 + quadrant_line_color: "theme.colors.text.body" + quadrant_line_dash: [0.15, 0.1] + + + # --------------------------------------------------------------------------- + # C16 — pyramid_level + # Niveau de pyramide. Instancié N fois du haut vers le bas. + # --------------------------------------------------------------------------- + pyramid_level: + id: C16 + category: visuel + description: "Un niveau de pyramide. Largeur proportionnelle au rang. Callout latéral optionnel." + used_by_layouts: + - pyramid + fields: + required: + label: + type: string + max_chars: 30 + description: "Texte inscrit dans le niveau" + rang: + type: integer + min: 1 + description: "1 = sommet (le plus petit), N = base (le plus large)" + optional: + description: + type: string + max_chars: 80 + description: "Callout latéral explicatif" + default: null + couleur: + type: string + default: null + constraints: + min_levels: 3 + max_levels: 5 + render: + colors_default: + - "theme.colors.primary.bright_blue" # rang 1 — sommet + - "theme.colors.primary.dark_blue" # rang 2 + - "theme.colors.secondary.cool_blue" # rang 3 + - "theme.colors.secondary.maize_yellow" # rang 4 + - "theme.colors.bright_warm" # rang 5 — base + label_font: "theme.typography.body" + label_size_pt: "theme.typography.sizes.body_small" + label_bold: true + callout_line_color: "theme.colors.text.body" + callout_font_size: "theme.typography.sizes.body_small" + callout_color: "theme.colors.text.body" + level_height_cm: 0.8 + + + # --------------------------------------------------------------------------- + # C17 — circular_segment + # Segment de diagramme circulaire + entrée de légende. Instancié N fois. + # --------------------------------------------------------------------------- + circular_segment: + id: C17 + category: visuel + description: "Segment du diagramme circulaire. Logo PR en médaillon central." + used_by_layouts: + - circular_diagram + fields: + required: + label: + type: string + max_chars: 30 + description: + type: string + max_chars: 80 + optional: + couleur: + type: string + default: null + poids: + type: number + min: 1 + max: 10 + default: 1 + description: "Poids relatif du segment (1 = égal aux autres)" + constraints: + min_segments: 3 + max_segments: 6 + render: + colors_default: + - "theme.colors.primary.bright_blue" + - "theme.colors.secondary.maize_yellow" + - "theme.colors.primary.dark_blue" + - "theme.colors.primary.bright_warm" + - "theme.colors.secondary.cool_blue" + - "theme.colors.secondary.barley_green" + center_logo: true + center_logo_file: "theme.assets.logo_sun" + center_circle_color: "#ffffff" + center_circle_diameter_cm: 1.5 + donut_outer_cm: 3.5 + legend_font_size: "theme.typography.sizes.body_small" + legend_dot_diameter_cm: 0.3 + legend_position: right + + + # --------------------------------------------------------------------------- + # C18 — from_to_pair + # Une paire FROM → TO. Instanciée N fois dans le layout from_to. + # --------------------------------------------------------------------------- + from_to_pair: + id: C18 + category: visuel + description: "Une ligne de transformation FROM (atténué) → TO (affirmé). Flèche cuivre." + used_by_layouts: + - from_to + fields: + required: + from: + type: string + max_chars: 60 + description: "État actuel / situation de départ" + to: + type: string + max_chars: 60 + description: "État cible / situation d'arrivée" + constraints: + min_pairs: 2 + max_pairs: 5 + render: + from_font: "theme.typography.body" + from_size_pt: "theme.typography.sizes.body" + from_color: "theme.colors.semantic.from_color" + from_opacity: 0.6 + arrow_char: "›" + arrow_color: "theme.colors.semantic.arrow_color" + arrow_size_pt: 14 + to_font: "theme.typography.body" + to_size_pt: "theme.typography.sizes.body" + to_color: "theme.colors.semantic.to_color" + to_bold: true + row_height_cm: 0.55 + separator_color: "theme.signature.footer.border_top_color" + + +# ============================================================================= +# CATÉGORIE : PROCESS +# ============================================================================= + + # --------------------------------------------------------------------------- + # C19 — step_item + # Étape numérotée verticale. Badge carré bleu + titre + description. + # --------------------------------------------------------------------------- + step_item: + id: C19 + category: process + description: "Étape numérotée. Badge carré dark blue, titre bold, description corps." + used_by_layouts: + - numbered_steps + fields: + required: + numero: + type: integer + min: 1 + max: 6 + titre: + type: string + max_chars: 50 + optional: + description: + type: string + max_chars: 120 + default: null + constraints: + min_steps: 2 + max_steps: 6 + render: + badge: + size_cm: "theme.shapes.step_badge.size_cm" + fill: "theme.shapes.step_badge.fill" + text_color: "theme.shapes.step_badge.text_color" + font: "theme.shapes.step_badge.font" + size_pt: "theme.shapes.step_badge.font_size_pt" + titre_font: "theme.typography.body" + titre_size_pt: "theme.typography.sizes.heading_2" + titre_color: "theme.colors.primary.dark_blue" + titre_bold: true + desc_font: "theme.typography.body" + desc_size_pt: "theme.typography.sizes.body" + desc_color: "theme.colors.text.body" + row_height_cm: 0.75 + separator: true + separator_color: "theme.signature.footer.border_top_color" + + + # --------------------------------------------------------------------------- + # C20 — chevron_step + # Chevron horizontal de process. 2 à 5 chevrons + triangle terminal. + # --------------------------------------------------------------------------- + chevron_step: + id: C20 + category: process + description: "Chevron process. Actif = dark blue / rose. Inactif = hague grey. Dernier = triangle." + used_by_layouts: + - process_arrow + fields: + required: + label: + type: string + max_chars: 20 + rang: + type: integer + min: 1 + description: "Position dans la séquence" + optional: + duree: + type: string + max_chars: 15 + default: null + actif: + type: boolean + default: false + description: "Phase courante — mise en avant visuelle" + bullets: + type: array + max_items: 4 + item_type: string + max_chars_each: 60 + default: null + terminal: + type: boolean + default: false + description: "Si true → forme triangle fermé (dernier chevron)" + constraints: + min_steps: 2 + max_steps: 5 + render: + fill_inactive: "theme.shapes.chevron.fill_default" + fill_active: "theme.shapes.chevron.fill_active" + text_inactive: "theme.shapes.chevron.text_default" + text_active: "theme.shapes.chevron.text_active" + height_cm: "theme.shapes.chevron.height_cm" + tip_width_cm: "theme.shapes.chevron.tip_width_cm" + label_font: "theme.typography.display" + label_size_pt: 14 + label_bold: true + duree_size_pt: "theme.typography.sizes.body_small" + bullet_size_pt: "theme.typography.sizes.body_small" + bullet_color: "theme.colors.text.body" + + + # --------------------------------------------------------------------------- + # C21 — gantt_bar + # Barre de tâche Gantt. Position et largeur calculées depuis start/end. + # --------------------------------------------------------------------------- + gantt_bar: + id: C21 + category: process + description: "Barre de tâche Gantt. Couleur par workstream. Décalage double-ligne possible." + used_by_layouts: + - gantt_timeline + fields: + required: + start: + type: string + format: "YYYY-MM" + description: "Mois de début (ex: 2026-05)" + end: + type: string + format: "YYYY-MM" + description: "Mois de fin (ex: 2026-08)" + optional: + label: + type: string + max_chars: 30 + default: null + couleur: + type: string + default: null + description: "Couleur de la barre — si null, couleur du workstream parent" + row: + type: integer + values: [1, 2] + default: 1 + description: "Ligne 1 (principale) ou 2 (décalée) dans le workstream" + render: + bar_height_cm: 0.28 + bar_radius: 0 + row_gap_cm: 0.08 + label_inside: false # texte affiché sous la barre si trop court + label_font_size: "theme.typography.sizes.body_small" + + + # --------------------------------------------------------------------------- + # C22 — timeline_milestone + # Jalon sur une timeline horizontale (yearly ou phases). + # --------------------------------------------------------------------------- + timeline_milestone: + id: C22 + category: process + description: "Jalon sur axe temporel. Cercle sur l'axe, année au-dessus, description dessous." + used_by_layouts: + - yearly_timeline + fields: + required: + annee: + type: string + max_chars: 10 + description: "Label de date (ex: 2025, Q1 2026)" + label: + type: string + max_chars: 40 + optional: + description: + type: string + max_chars: 80 + default: null + actif: + type: boolean + default: false + description: "Jalon courant — cercle en rose" + constraints: + min_milestones: 3 + max_milestones: 6 + render: + circle_diameter_cm: 0.28 + circle_fill_default: "theme.colors.primary.dark_blue" + circle_fill_active: "theme.colors.primary.rose" + axis_color: "theme.colors.text.body" + axis_stroke_cm: 0.04 + arrow_tip: true # flèche → à droite de l'axe + annee_font: "theme.typography.display" + annee_size_pt: 13 + annee_color_default: "theme.colors.primary.bright_blue" + annee_color_active: "theme.colors.primary.rose" + annee_bold: true + label_font: "theme.typography.body" + label_size_pt: "theme.typography.sizes.body_small" + label_color: "theme.colors.primary.dark_blue" + desc_font: "theme.typography.body" + desc_size_pt: "theme.typography.sizes.body_small" + desc_color: "theme.colors.text.body" + + +# ============================================================================= +# CATÉGORIE : ACTEURS +# ============================================================================= + + # --------------------------------------------------------------------------- + # C23 — org_node + # Nœud d'organigramme. Couleur selon niveau hiérarchique. + # --------------------------------------------------------------------------- + org_node: + id: C23 + category: acteurs + description: "Box d'organigramme. Couleur par niveau. Connexions orthogonales auto." + used_by_layouts: + - org_chart + fields: + required: + label: + type: string + max_chars: 35 + niveau: + type: integer + min: 1 + max: 4 + description: "1 = racine, 2 = managers, 3 = équipe, 4 = sous-équipe" + optional: + sous_label: + type: string + max_chars: 30 + default: null + constraints: + max_total_nodes: 20 + render: + colors_by_level: + 1: "theme.colors.primary.dark_blue" # racine + 2: "theme.colors.secondary.barley_green" # niveau 2 + 3: "theme.colors.secondary.cool_blue" # niveau 3 + 4: "theme.colors.primary.bright_warm" # niveau 4 + text_colors_by_level: + 1: "#ffffff" + 2: "#ffffff" + 3: "theme.colors.primary.dark_blue" + 4: "theme.colors.primary.dark_blue" + box_height_cm: 0.65 + box_min_width_cm: 2.5 + box_radius: 0 + font: "theme.typography.body" + size_pt: "theme.typography.sizes.body_small" + bold: true + connector_color: "theme.colors.text.body" + connector_width_cm: 0.03 + connector_style: orthogonal + + + # --------------------------------------------------------------------------- + # C24 — raci_cell + # Cellule de matrice RACI. Pastille colorée selon le rôle (R/A/C/I). + # --------------------------------------------------------------------------- + raci_cell: + id: C24 + category: acteurs + description: "Cellule RACI. Pastille circulaire : R/A en vert, C/I en bleu clair." + used_by_layouts: + - raci_table + fields: + required: + role: + type: string + values: ["R", "A", "C", "I", ""] + description: "R=Responsible, A=Accountable, C=Consulted, I=Informed, ''=vide" + render: + R: + fill: "theme.colors.semantic.responsible" + opacity: 1.0 + text_color: "#ffffff" + A: + fill: "theme.colors.semantic.accountable" + opacity: 1.0 + text_color: "#ffffff" + C: + fill: "theme.colors.semantic.consulted" + opacity: 1.0 + text_color: "theme.colors.primary.dark_blue" + I: + fill: "theme.colors.semantic.informed" + opacity: 0.45 + text_color: "theme.colors.primary.dark_blue" + empty: + fill: "transparent" + text_color: "transparent" + circle_diameter_cm: 0.35 + font: "theme.typography.body" + size_pt: "theme.typography.sizes.body_small" + bold: true + row_header: + background: "theme.colors.backgrounds.content_area" + font: "theme.typography.body" + size_pt: "theme.typography.sizes.body_small" + color: "theme.colors.text.body" + col_header: + background: "theme.colors.primary.dark_blue" + text_color: "#ffffff" + font: "theme.typography.body" + size_pt: "theme.typography.sizes.body_small" + bold: true + height_cm: 0.65 + row_separator_color: "theme.signature.footer.border_top_color" + + + # --------------------------------------------------------------------------- + # C25 — decision_node + # Nœud d'arbre de décision. Question (rect arrondi), options (rect clair). + # Connexions avec labels YES/NO. + # --------------------------------------------------------------------------- + decision_node: + id: C25 + category: acteurs + description: "Nœud d'arbre de décision. Question centrale, branches YES/NO, options terminales." + used_by_layouts: + - decision_tree + fields: + required: + type: + type: string + values: [question, branch, option] + description: "question = nœud racine, branch = nœud intermédiaire, option = feuille" + label: + type: string + max_chars: 80 + optional: + branch_label: + type: string + values: ["YES", "NO", null] + default: null + description: "Label sur le connecteur entrant" + actif: + type: boolean + default: false + description: "Branche recommandée — mise en avant en rose" + render: + question: + fill: "theme.colors.backgrounds.content_area" + border_color: "theme.colors.text.body" + border_width: 0.03 + radius: 0.15 + font: "theme.typography.body" + size_pt: "theme.typography.sizes.body_small" + color: "theme.colors.primary.dark_blue" + branch: + fill: "theme.colors.primary.bright_warm" + font: "theme.typography.body" + size_pt: "theme.typography.sizes.body_small" + color: "theme.colors.primary.dark_blue" + radius: 0.15 + branch_actif: + fill: "theme.colors.primary.rose" + opacity: 0.7 + option: + fill: "theme.colors.backgrounds.content_area" + font: "theme.typography.body" + size_pt: "theme.typography.sizes.body_small" + color: "theme.colors.primary.dark_blue" + radius: 0.15 + connector_color: "theme.colors.text.body" + connector_width: 0.03 + branch_label_font_size: "theme.typography.sizes.body_small" + branch_label_bold: true + branch_label_color: "theme.colors.text.body" + + + # --------------------------------------------------------------------------- + # C26 — recommendation_sidebar + # Panneau gauche jaune de recommendation_card. + # Numéro circulaire + titre vertical + résumé + flèche + CTA. + # --------------------------------------------------------------------------- + recommendation_sidebar: + id: C26 + category: acteurs + description: "Sidebar jaune de carte recommandation. Numéro, titre, résumé, CTA." + used_by_layouts: + - recommendation_card + fields: + required: + numero: + type: integer + min: 1 + max: 9 + titre: + type: string + max_chars: 30 + description: "Intitulé court de la recommandation" + optional: + resume: + type: string + max_chars: 120 + description: "Résumé en 1-2 phrases" + default: null + cta: + type: string + max_chars: 30 + description: "Call to action — texte du bouton bas" + default: null + render: + background: "theme.shapes.recommendation_sidebar.fill" + width_cm: "theme.shapes.recommendation_sidebar.width_cm" + numero_circle: + fill: "theme.colors.primary.dark_blue" + diameter_cm: 0.9 + text_color: "#ffffff" + font: "theme.typography.display" + size_pt: 18 + titre_font: "theme.typography.display" + titre_size_pt: "theme.typography.sizes.heading_1" + titre_color: "theme.colors.primary.dark_blue" + titre_bold: true + resume_font: "theme.typography.body" + resume_size_pt: "theme.typography.sizes.body_small" + resume_color: "theme.colors.text.body" + arrow_color: "theme.colors.primary.dark_blue" + cta_fill: "theme.colors.primary.dark_blue" + cta_text_color: "#ffffff" + cta_font_size: "theme.typography.sizes.body_small" + cta_bold: true + + +# ============================================================================= +# INDEX DE RÉFÉRENCE CROISÉE +# layouts → composants utilisés +# ============================================================================= + +layout_to_components: + cover_split: [C01, C04] # pas de C02/C03 — traitement spécial cover + section_divider: [C01, C04] + agenda: [C01, C02, C03, C04, C05] + content_marker: [C01, C04] + end_slide: [C01, C04] + default_bullets: [C01, C02, C03, C04, C05, C06] + two_cols_text: [C01, C02, C03, C04, C05, C06, C07] + key_message: [C01, C02, C03, C04, C08] + executive_summary: [C01, C02, C03, C04, C05, C07] + kpi_grid: [C01, C02, C03, C04, C05, C09] + big_stat: [C01, C02, C03, C04, C05, C10] + comparison_table: [C01, C02, C03, C04, C05, C11] + chart_callout: [C01, C02, C03, C04, C05, C12, C13] + benchmark: [C01, C02, C03, C04, C05, C14] + matrix_2x2: [C01, C02, C03, C04, C05, C15] + pyramid: [C01, C02, C03, C04, C05, C16] + circular_diagram: [C01, C02, C03, C04, C05, C17] + from_to: [C01, C02, C03, C04, C05, C07, C18] + boxes_grid: [C01, C02, C03, C04, C05, C11] + numbered_steps: [C01, C02, C03, C04, C05, C19] + process_arrow: [C01, C02, C03, C04, C05, C20] + gantt_timeline: [C01, C02, C03, C04, C05, C21] + yearly_timeline: [C01, C02, C03, C04, C05, C22] + phases_timeline: [C01, C02, C03, C04, C05, C22] + org_chart: [C01, C02, C03, C04, C05, C23] + raci_table: [C01, C02, C03, C04, C05, C24] + decision_tree: [C01, C02, C03, C04, C05, C25] + recommendation_card: [C01, C02, C04, C06, C26] diff --git a/archive/v1_pipeline/facilitator.py b/archive/v1_pipeline/facilitator.py new file mode 100644 index 0000000..6867d01 --- /dev/null +++ b/archive/v1_pipeline/facilitator.py @@ -0,0 +1,620 @@ +#!/usr/bin/env python3 +""" +facilitator.py — Sliding Pipeline v3 · Pernod Ricard +===================================================== +Orchestre les 3 agents du nouveau pipeline : + + The Narrator (Agent 1) → Markdown narratif + ↓ approbation Bastien + The Designer (Agent 2) → Plan de présentation texte + ↓ approbation optionnelle + The Encoder (Agent 3) → YAML valide + ↓ + render_engine.py → PPTX + +Variables .env requises : + MISTRAL_API_KEY + NARRATOR_AGENT_ID + DESIGNER_AGENT_ID + ENCODER_AGENT_ID + OUTPUT_DIR (dossier de sortie des YAML/PPTX, défaut : ./output) + THEME_PATH (défaut : theme.yaml) + COMPONENTS_PATH (défaut : components.yaml) + LAYOUTS_PATH (défaut : layouts.yaml) +""" + +import json +import os +import re +import sys +import textwrap +from datetime import datetime +from pathlib import Path + +import requests +import yaml +from dotenv import load_dotenv +from typing import Optional, Tuple, Dict, Union, List + +load_dotenv() + +# ───────────────────────────────────────────────────────────────────────────── +# CONFIGURATION +# ───────────────────────────────────────────────────────────────────────────── + +API_KEY = os.getenv("MISTRAL_API_KEY") +NARRATOR_ID = os.getenv("NARRATOR_AGENT_ID") +DESIGNER_ID = os.getenv("DESIGNER_AGENT_ID") +ENCODER_ID = os.getenv("ENCODER_AGENT_ID") +OUTPUT_DIR = Path(os.getenv("OUTPUT_DIR", "./output")) +THEME_PATH = os.getenv("THEME_PATH", "theme.yaml") +COMPONENTS_PATH = os.getenv("COMPONENTS_PATH", "components.yaml") +LAYOUTS_PATH = os.getenv("LAYOUTS_PATH", "layouts.yaml") + +HEADERS = { + "Authorization": f"Bearer {API_KEY}", + "Content-Type": "application/json", +} + +VERSION = "3.0" + +OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + +# ───────────────────────────────────────────────────────────────────────────── +# UTILITAIRES D'AFFICHAGE +# ───────────────────────────────────────────────────────────────────────────── + +def banner(): + print("\n" + "═" * 62) + print(f" SLIDING PIPELINE v{VERSION} — Pernod Ricard") + print(f" {datetime.now().strftime('%d/%m/%Y %H:%M')}") + print("═" * 62 + "\n") + + +def section(title: str): + print(f"\n{'─' * 62}") + print(f" {title}") + print(f"{'─' * 62}\n") + + +def info(msg: str): + print(f" ℹ {msg}") + + +def ok(msg: str): + print(f" ✓ {msg}") + + +def warn(msg: str): + print(f" ⚠ {msg}") + + +def ask(prompt: str) -> str: + try: + return input(f"\n {prompt} ").strip() + except (EOFError, KeyboardInterrupt): + print("\n Session interrompue.") + sys.exit(0) + + +def display_markdown(text: str, max_lines: int = 60): + """Affiche le Markdown avec un max de lignes.""" + lines = text.splitlines() + if len(lines) <= max_lines: + print(textwrap.indent(text, " ")) + else: + preview = "\n".join(lines[:max_lines]) + print(textwrap.indent(preview, " ")) + print(f"\n ... [{len(lines) - max_lines} lignes supplémentaires — " + f"choisissez [4] pour tout voir]") + + +def display_plan(text: str, max_lines: int = 80): + """Affiche le plan du Designer — extrait les lignes SLIDE pour un résumé rapide.""" + slides = [l for l in text.splitlines() if l.strip().startswith("SLIDE")] + if slides: + print(" Résumé du plan :\n") + for s in slides: + print(f" {s.strip()}") + print(f"\n ({len(slides)} slides au total)") + else: + display_markdown(text, max_lines) + + +# ───────────────────────────────────────────────────────────────────────────── +# APPEL AGENT MISTRAL (avec gestion PAUSE/continue) +# ───────────────────────────────────────────────────────────────────────────── + +def call_agent(agent_id: str, messages: list) -> str: + """ + Appelle un agent Mistral et gère la pagination PAUSE/continue. + Retourne la réponse complète concaténée. + """ + full_response = "" + current_messages = messages.copy() + + while True: + resp = requests.post( + "https://api.mistral.ai/v1/agents/completions", + headers=HEADERS, + json={"agent_id": agent_id, "messages": current_messages}, + timeout=120, + ) + resp.raise_for_status() + content = resp.json()["choices"][0]["message"]["content"] + full_response += content + + # Détection pagination + if "PAUSE" in content and "FIN —" not in content: + info("Pagination détectée → continuation automatique...") + current_messages.append({"role": "assistant", "content": content}) + current_messages.append({"role": "user", "content": "continue"}) + else: + break + + return full_response + + +def call_with_feedback(agent_id: str, initial_message: str, + feedback: str, previous_output: str) -> str: + """Relance un agent avec le feedback utilisateur et la sortie précédente.""" + messages = [ + {"role": "user", "content": initial_message}, + {"role": "assistant", "content": previous_output}, + {"role": "user", "content": feedback}, + ] + return call_agent(agent_id, messages) + + +# ───────────────────────────────────────────────────────────────────────────── +# VALIDATION YAML (The Encoder) +# ───────────────────────────────────────────────────────────────────────────── + +def extract_yaml(raw: str) -> str: + """ + Extrait le bloc YAML d'une réponse (enlève les ```yaml ... ``` si présents). + """ + # Bloc markdown yaml + match = re.search(r"```ya?ml\s*(.*?)```", raw, re.DOTALL | re.IGNORECASE) + if match: + return match.group(1).strip() + # Pas de bloc → retourne tel quel + return raw.strip() + + +def validate_yaml(raw: str, layouts: dict) -> tuple[bool, str, Optional[dict]]: + """ + Valide la syntaxe YAML et vérifie les contraintes de base. + Retourne (is_valid, message_erreur, data_parsée). + """ + try: + content = extract_yaml(raw) + data = yaml.safe_load(content) + except yaml.YAMLError as e: + return False, f"Erreur de syntaxe YAML : {e}", None + + if not isinstance(data, dict): + return False, "Le YAML doit être un dictionnaire à la racine.", None + + slides = data.get("slides") + if not slides: + return False, "Clé 'slides' manquante ou vide.", None + + errors = [] + valid_layouts = set(layouts.keys()) + + for i, slide in enumerate(slides): + pos = slide.get("position", i + 1) + layout = slide.get("layout", "") + + if not layout: + errors.append(f"Slide {pos} : clé 'layout' manquante") + continue + + if layout not in valid_layouts: + errors.append(f"Slide {pos} : layout '{layout}' inconnu " + f"(valides : {', '.join(sorted(valid_layouts)[:5])}...)") + continue + + # Vérification des champs requis + schema = layouts[layout].get("json_schema", {}) + required_fields = schema.get("required", []) + for field in required_fields: + if field not in slide: + errors.append(f"Slide {pos} ({layout}) : champ requis '{field}' manquant") + + if errors: + return False, "\n ".join(errors), data + + return True, f"{len(slides)} slides valides.", data + + +# ───────────────────────────────────────────────────────────────────────────── +# SAUVEGARDE +# ───────────────────────────────────────────────────────────────────────────── + +def save_output(content: str, suffix: str, ext: str) -> Path: + """Sauvegarde un fichier de sortie avec timestamp.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = OUTPUT_DIR / f"sliding_{timestamp}_{suffix}.{ext}" + filename.write_text(content, encoding="utf-8") + ok(f"Sauvegardé : {filename}") + return filename + + +# ───────────────────────────────────────────────────────────────────────────── +# CHARGEMENT DES LAYOUTS (pour validation) +# ───────────────────────────────────────────────────────────────────────────── + +def load_layouts() -> dict: + if not os.path.exists(LAYOUTS_PATH): + warn(f"layouts.yaml introuvable ({LAYOUTS_PATH}) — validation désactivée") + return {} + with open(LAYOUTS_PATH, encoding="utf-8") as f: + data = yaml.safe_load(f) + return data.get("layouts", {}) + + +# ───────────────────────────────────────────────────────────────────────────── +# ÉTAPE 1 — THE NARRATOR +# ───────────────────────────────────────────────────────────────────────────── + +def run_narrator(brief: str) -> str: + """ + Lance The Narrator sur le brief. + Boucle d'approbation : valider / feedback / régénérer / afficher. + Retourne le Markdown approuvé. + """ + section("ÉTAPE 1 — THE NARRATOR") + info("Génération du Markdown narratif...") + + messages = [{"role": "user", "content": brief}] + output = call_agent(NARRATOR_ID, messages) + ok(f"Markdown généré ({len(output)} caractères)") + + while True: + print() + display_markdown(output) + + print("\n [1] Valider → passer au Designer") + print(" [2] Donner du feedback au Narrator") + print(" [3] Régénérer complètement") + print(" [4] Afficher le Markdown complet") + print(" [5] Sauvegarder le Markdown") + + choix = ask("Votre choix :") + + if choix == "1": + ok("Markdown approuvé. Passage au Designer...") + save_output(output, "narrator", "md") + return output + + elif choix == "2": + feedback = ask("Votre feedback :") + if not feedback: + continue + info("Envoi du feedback au Narrator...") + output = call_with_feedback(NARRATOR_ID, brief, feedback, output) + ok(f"Nouveau Markdown ({len(output)} caractères)") + + elif choix == "3": + info("Régénération depuis le brief...") + messages = [{"role": "user", "content": brief}] + output = call_agent(NARRATOR_ID, messages) + ok(f"Nouveau Markdown ({len(output)} caractères)") + + elif choix == "4": + print() + print(textwrap.indent(output, " ")) + + elif choix == "5": + save_output(output, "narrator_draft", "md") + + else: + warn("Choix invalide. Entrez 1 à 5.") + + +# ───────────────────────────────────────────────────────────────────────────── +# ÉTAPE 2 — THE DESIGNER +# ───────────────────────────────────────────────────────────────────────────── + +def run_designer(markdown: str) -> str: + """ + Lance The Designer sur le Markdown approuvé. + Boucle d'approbation légère : valider / feedback / afficher. + Retourne le plan texte structuré. + """ + section("ÉTAPE 2 — THE DESIGNER") + info("Génération du plan de présentation...") + + messages = [{"role": "user", "content": markdown}] + output = call_agent(DESIGNER_ID, messages) + ok(f"Plan généré ({len(output)} caractères)") + + while True: + print() + display_plan(output) + + print("\n [1] Valider → passer à l'Encoder") + print(" [2] Donner du feedback au Designer") + print(" [3] Afficher le plan complet") + print(" [4] Sauvegarder le plan") + print(" [0] Revenir au Narrator (modifier le Markdown)") + + choix = ask("Votre choix :") + + if choix == "1": + ok("Plan approuvé. Passage à l'Encoder...") + save_output(output, "designer", "txt") + return output + + elif choix == "2": + feedback = ask("Votre feedback :") + if not feedback: + continue + info("Envoi du feedback au Designer...") + output = call_with_feedback(DESIGNER_ID, markdown, feedback, output) + ok(f"Nouveau plan ({len(output)} caractères)") + + elif choix == "3": + print() + print(textwrap.indent(output, " ")) + + elif choix == "4": + save_output(output, "designer_draft", "txt") + + elif choix == "0": + return None # Signal : retour au Narrator + + else: + warn("Choix invalide. Entrez 0 à 4.") + + +# ───────────────────────────────────────────────────────────────────────────── +# ÉTAPE 3 — THE ENCODER +# ───────────────────────────────────────────────────────────────────────────── + +def run_encoder(plan: str, layouts: dict) -> tuple[str, dict]: + """ + Lance The Encoder sur le plan du Designer. + Valide le YAML produit. Boucle jusqu'à YAML valide ou abandon. + Retourne (yaml_string, yaml_dict). + """ + section("ÉTAPE 3 — THE ENCODER") + info("Encodage du plan en YAML...") + + messages = [{"role": "user", "content": plan}] + output = call_agent(ENCODER_ID, messages) + ok(f"YAML brut reçu ({len(output)} caractères)") + + attempts = 0 + max_attempts = 3 + + while attempts < max_attempts: + is_valid, message, data = validate_yaml(output, layouts) + + if is_valid: + ok(f"YAML valide : {message}") + yaml_clean = extract_yaml(output) + save_output(yaml_clean, "encoder", "yaml") + return yaml_clean, data + + else: + attempts += 1 + warn(f"YAML invalide (tentative {attempts}/{max_attempts}) :") + print(f" {message}\n") + + if attempts >= max_attempts: + warn("Nombre maximum de tentatives atteint.") + break + + info("Correction automatique...") + correction_msg = ( + f"Le YAML que tu as généré contient des erreurs :\n\n" + f"{message}\n\n" + f"Corrige ces erreurs et renvoie uniquement le YAML corrigé " + f"sans aucun texte avant ou après." + ) + output = call_with_feedback(ENCODER_ID, plan, correction_msg, output) + ok(f"YAML corrigé reçu ({len(output)} caractères)") + + # Après max_attempts : choix utilisateur + print() + print(" [1] Accepter le YAML tel quel (avec erreurs)") + print(" [2] Donner un feedback manuel à l'Encoder") + print(" [0] Abandonner et revenir au Designer") + + choix = ask("Votre choix :") + + if choix == "1": + yaml_clean = extract_yaml(output) + save_output(yaml_clean, "encoder_unvalidated", "yaml") + try: + data = yaml.safe_load(yaml_clean) or {} + except Exception: + data = {} + return yaml_clean, data + + elif choix == "2": + feedback = ask("Votre feedback :") + output = call_with_feedback(ENCODER_ID, plan, feedback, output) + yaml_clean = extract_yaml(output) + save_output(yaml_clean, "encoder_manual", "yaml") + try: + data = yaml.safe_load(yaml_clean) or {} + except Exception: + data = {} + return yaml_clean, data + + else: + return None, None # Signal : retour au Designer + + +# ───────────────────────────────────────────────────────────────────────────── +# ÉTAPE 4 — RENDER ENGINE +# ───────────────────────────────────────────────────────────────────────────── + +def run_render(yaml_data: dict) -> Optional[Path]: + """ + Appelle render_engine.py avec le YAML validé. + Retourne le chemin du PPTX généré, ou None si erreur. + """ + section("ÉTAPE 4 — RENDER ENGINE") + + # Vérifie que render_engine.py est accessible + render_path = Path("render_engine.py") + if not render_path.exists(): + warn("render_engine.py introuvable dans le dossier courant.") + warn("Vérifiez que render_engine.py est dans le même dossier que facilitator.py") + return None + + # Vérifie les YAML de config + for path in [THEME_PATH, COMPONENTS_PATH, LAYOUTS_PATH]: + if not os.path.exists(path): + warn(f"Fichier YAML manquant : {path}") + return None + + # Sauvegarde temporaire du YAML pour render_engine + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + yaml_tmp = OUTPUT_DIR / f"sliding_{timestamp}_input.yaml" + pptx_out = OUTPUT_DIR / f"sliding_{timestamp}.pptx" + + if not yaml_data: + warn("yaml_data est vide — impossible de générer le PPTX.") + return None + + yaml_content = yaml.dump(yaml_data, allow_unicode=True, default_flow_style=False) + yaml_tmp.write_text(yaml_content, encoding="utf-8") + + # Vérification que le fichier n'est pas vide + if yaml_tmp.stat().st_size == 0: + warn(f"Fichier YAML temporaire vide : {yaml_tmp}") + return None + + info(f"Lancement de render_engine.py...") + info(f"Sortie : {pptx_out}") + + import subprocess + result = subprocess.run( + [ + sys.executable, str(render_path), + str(yaml_tmp), str(pptx_out), + "--theme", THEME_PATH, + "--components", COMPONENTS_PATH, + "--layouts", LAYOUTS_PATH, + ], + capture_output=True, + text=True, + ) + + if result.returncode == 0: + ok(result.stdout.strip() or f"PPTX généré : {pptx_out}") + return pptx_out + else: + warn("Erreur dans render_engine.py :") + print(textwrap.indent(result.stderr or result.stdout, " ")) + return None + + +# ───────────────────────────────────────────────────────────────────────────── +# BOUCLE PRINCIPALE +# ───────────────────────────────────────────────────────────────────────────── + +def run(): + banner() + + # Vérification config minimale + missing = [k for k, v in { + "MISTRAL_API_KEY": API_KEY, + "NARRATOR_AGENT_ID": NARRATOR_ID, + "DESIGNER_AGENT_ID": DESIGNER_ID, + "ENCODER_AGENT_ID": ENCODER_ID, + }.items() if not v] + + if missing: + for m in missing: + warn(f"Variable .env manquante : {m}") + sys.exit(1) + + # Chargement layouts pour validation + layouts = load_layouts() + if layouts: + ok(f"layouts.yaml chargé — {len(layouts)} layouts") + + print(" Commandes : brief libre | quit") + print(" Tapez votre brief pour démarrer une nouvelle présentation.\n") + + # ── Boucle de session ───────────────────────────────────── + while True: + try: + brief = input(" Vous : ").strip() + except (EOFError, KeyboardInterrupt): + print("\n Session terminée.") + break + + if brief.lower() in ("quit", "exit", "q"): + print(" Au revoir.") + break + + if not brief: + continue + + # ── Pipeline complet ────────────────────────────────── + + # ÉTAPE 1 — Narrator + markdown = run_narrator(brief) + if not markdown: + continue + + # ÉTAPE 2 — Designer (avec possibilité de revenir au Narrator) + while True: + plan = run_designer(markdown) + + if plan is None: + # Retour au Narrator + section("RETOUR AU NARRATOR") + info("Donnez un feedback pour modifier le Markdown :") + feedback = ask("Feedback :") + if feedback: + markdown = call_with_feedback( + NARRATOR_ID, brief, feedback, markdown) + ok(f"Markdown mis à jour ({len(markdown)} caractères)") + continue + + # ÉTAPE 3 — Encoder (avec possibilité de revenir au Designer) + yaml_str, yaml_data = run_encoder(plan, layouts) + + if yaml_str is None: + # Retour au Designer + info("Retour au Designer...") + continue + + break # YAML OK → on sort de la boucle Designer/Encoder + + # ÉTAPE 4 — Render + if yaml_data: + pptx_path = run_render(yaml_data) + + if pptx_path and pptx_path.exists(): + section("PRÉSENTATION GÉNÉRÉE") + ok(f"Fichier PPTX : {pptx_path}") + ok(f"Taille : {pptx_path.stat().st_size / 1024:.1f} Ko") + else: + warn("Le PPTX n'a pas pu être généré.") + info("Le YAML est disponible dans le dossier output/.") + + # ── Prochaine présentation ? ────────────────────────── + print() + suite = ask("Générer une nouvelle présentation ? (o/n) :").lower() + if suite not in ("o", "oui", "y", "yes"): + print(" Au revoir.") + break + + +# ───────────────────────────────────────────────────────────────────────────── +# POINT D'ENTRÉE +# ───────────────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + run() diff --git a/archive/v1_pipeline/layouts.yaml b/archive/v1_pipeline/layouts.yaml new file mode 100644 index 0000000..a599aae --- /dev/null +++ b/archive/v1_pipeline/layouts.yaml @@ -0,0 +1,1743 @@ +# ============================================================================= +# layouts.yaml — 28 layouts Pernod Ricard Sliding +# Projet : Sliding Design System +# Usage : lu par render_engine.py pour assembler composants + positions +# Dépend : theme.yaml (couleurs, typo, spacing) + components.yaml (briques) +# ============================================================================= +# CONVENTION DE COORDONNÉES +# Origine : coin supérieur gauche du slide +# Unité : centimètres (cm) +# Slide : 33.87 cm × 19.05 cm (16:9) +# Zone utile (hors footer) : 33.87 × 17.85 cm +# Footer : y = 18.35 cm, hauteur 0.70 cm +# +# ZONES PRÉDÉFINIES (calculées depuis theme.yaml) +# TITLE_ZONE : left=1.80, top=0.45, width=30.00, height=1.90 +# CONTENT_ZONE: left=1.50, top=2.80, width=30.87, height=14.85 +# FULL_ZONE : left=1.50, top=0.45, width=30.87, height=17.20 +# +# STRUCTURE D'UN LAYOUT +# meta : infos catalogue (famille, rôles narratifs, source) +# background : paramètres C01 slide_background +# signature : éléments PR fixes (accent_bar, logo, footer) +# title_zone : paramètres C02 slide_title +# content_zones: liste de zones nommées avec composant + position +# json_schema : champs attendus dans le JSON Agent (validation Agent 3) +# constraints : règles min/max pour Agent 3 +# agent_hint : conseil narratif pour Agent 1/2 +# ============================================================================= + +meta_global: + version: "1.0" + date: "2026-05-14" + total_layouts: 28 + slide_width_cm: 33.87 + slide_height_cm: 19.05 + content_zone_top_cm: 2.80 # sous le titre + content_zone_bottom_cm: 18.05 # au-dessus du footer + content_height_cm: 15.25 + footer_top_cm: 18.35 + footer_height_cm: 0.70 + + +layouts: + + # =========================================================================== + # FAMILLE 1 — COUVERTURE & NAVIGATION (L01-L05) + # =========================================================================== + + # --------------------------------------------------------------------------- + # L01 — cover_split + # --------------------------------------------------------------------------- + cover_split: + id: L01 + famille: "Couverture & navigation" + source: "PR Template p.1, Data Awareness" + roles_narratifs: [accroche] + agent_hint: > + Réservé au slide 1. Titre fort et accrocheur, sous-titre contextualisé. + Pas de bullets. Message = raison d'être de la présentation en 1 phrase. + + background: + component: C01 + color: "theme.colors.primary.dark_blue" + diagonal_split: true + color_right: "theme.colors.primary.mid_blue" + diagonal_angle_deg: 15 + + signature: + accent_bar: false + logo_topbar: false + footer: false + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 6.50 + width_cm: 18.00 + height_cm: 4.00 + font_override: + size_pt: 40 + color: "#ffffff" + bold: true + subtitle: + margin_top_cm: 0.50 + size_pt: 20 + color: "theme.colors.primary.bright_blue" + + content_zones: [] # pas de contenu — cover pur + + json_schema: + required: + - titre + optional: + - sous_titre + - accroche + constraints: + titre_max_chars: 70 + sous_titre_max_chars: 80 + + + # --------------------------------------------------------------------------- + # L02 — section_divider + # --------------------------------------------------------------------------- + section_divider: + id: L02 + famille: "Couverture & navigation" + source: "PR Template p.8-9" + roles_narratifs: [transition] + agent_hint: > + Slide de transition entre parties. Titre = nom de la section, percutant. + Numéro de section bien visible. Pas de contenu textuel. + + background: + component: C01 + color: "theme.colors.primary.dark_blue" + diagonal_split: true + color_right: "#fdfcf9" + diagonal_angle_deg: 18 + + signature: + accent_bar: false + logo_topbar: false + footer: false + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 7.50 + width_cm: 17.00 + height_cm: 3.00 + font_override: + size_pt: 32 + color: "#ffffff" + bold: true + + content_zones: + - id: section_number + component: C01 # forme cercle rendu par render_engine + type: section_circle + center_x_cm: 25.50 + center_y_cm: 9.50 + diameter_cm: 2.80 + fill: "theme.colors.primary.dark_blue" + text_color: "#ffffff" + font: "theme.typography.display" + size_pt: 32 + + json_schema: + required: + - titre + - numero_section + optional: + - image + constraints: + titre_max_chars: 60 + numero_section_type: integer + + + # --------------------------------------------------------------------------- + # L03 — agenda + # --------------------------------------------------------------------------- + agenda: + id: L03 + famille: "Couverture & navigation" + source: "PR Template p.4-7" + roles_narratifs: [transition, contexte] + agent_hint: > + Sommaire de la présentation. Items = titres des sections, pas des bullets de contenu. + Présenter l'ossature narrative, pas le détail. + + background: + component: C01 + color: "#ffffff" + + signature: + accent_bar: true + logo_topbar: true + footer: true + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 0.45 + width_cm: 30.00 + height_cm: 1.90 + + content_zones: + - id: agenda_items + component: C06 # bullet_list en mode agenda (cercles numérotés) + type: agenda_numbered # variante spéciale : cercles au lieu de puces + left_cm: 1.50 + top_cm: 2.80 + width_cm: 30.87 + height_cm: 14.85 + cols: 2 # 2 colonnes si items > 3 + col_gap_cm: 2.00 + circle_diameter_cm: 0.80 + circle_fill: "theme.colors.primary.dark_blue" + item_height_cm: 1.40 + + json_schema: + required: + - titre + - items + optional: [] + constraints: + items_min: 2 + items_max: 6 + item_schema: + required: [numero, titre] + optional: [presentateur] + titre_max_chars: 50 + presentateur_max_chars: 30 + + + # --------------------------------------------------------------------------- + # L04 — content_marker + # --------------------------------------------------------------------------- + content_marker: + id: L04 + famille: "Couverture & navigation" + source: "Best practice 01 p.2-25" + roles_narratifs: [transition] + agent_hint: > + Rappel de l'agenda en cours de présentation. current_index indique + l'item actif (en orange). Identique à agenda en structure. + + background: + component: C01 + color: "theme.colors.primary.dark_blue" + + signature: + accent_bar: false + logo_topbar: false + footer: false + + title_zone: null # pas de titre — le marqueur fait office de titre + + content_zones: + - id: marker_items + component: C06 + type: agenda_marker # variante : item actif en rose, autres atténués + left_cm: 3.00 + top_cm: 4.00 + width_cm: 26.00 + height_cm: 12.00 + active_fill: "theme.colors.primary.rose" + inactive_fill: "rgba(255,255,255,0.25)" + active_text: "#ffffff" + inactive_text: "rgba(255,255,255,0.5)" + item_height_cm: 1.60 + + json_schema: + required: + - items + - current_index + optional: [] + constraints: + items_min: 2 + items_max: 6 + current_index_type: integer + + + # --------------------------------------------------------------------------- + # L05 — end_slide + # --------------------------------------------------------------------------- + end_slide: + id: L05 + famille: "Couverture & navigation" + source: "Data Awareness V2 p.20" + roles_narratifs: [conclusion, next-steps] + agent_hint: > + Dernier slide obligatoire. Titre = message de clôture fort. + Next steps = 2-4 actions concrètes. Contact optionnel. + + background: + component: C01 + color: "theme.colors.primary.dark_blue" + + signature: + accent_bar: false + logo_topbar: false + footer: false + accent_bar_left_full: # barre rose pleine hauteur côté gauche + color: "theme.colors.primary.rose" + width_cm: 0.40 + left_cm: 0.00 + + title_zone: + component: C02 + left_cm: 2.50 + top_cm: 5.50 + width_cm: 22.00 + height_cm: 3.00 + font_override: + size_pt: 32 + color: "#ffffff" + bold: true + subtitle: + size_pt: 18 + color: "theme.colors.primary.bright_blue" + + content_zones: + - id: next_steps + component: C06 + type: bullet_list + left_cm: 2.50 + top_cm: 10.00 + width_cm: 22.00 + height_cm: 5.00 + font_override: + color: "rgba(255,255,255,0.85)" + size_pt: 12 + - id: contact + component: C07 + type: text_paragraph + left_cm: 2.50 + top_cm: 16.00 + width_cm: 22.00 + height_cm: 1.80 + font_override: + color: "rgba(255,255,255,0.60)" + size_pt: 10 + + json_schema: + required: + - titre + optional: + - sous_titre + - message + - next_steps + - contacts + constraints: + titre_max_chars: 70 + next_steps_max: 4 + + + # =========================================================================== + # FAMILLE 2 — TEXTE STRUCTURÉ (L06-L09) + # =========================================================================== + + # --------------------------------------------------------------------------- + # L06 — default_bullets + # --------------------------------------------------------------------------- + default_bullets: + id: L06 + famille: "Texte structuré" + source: "PR Template p.10" + roles_narratifs: [contexte, solution, preuve] + agent_hint: > + Layout texte par défaut. Titre = So What affirmatif. + Bullets L1 = arguments principaux, L2 = preuves/détails, L3 = exemples. + Max 5 bullets L1 pour rester lisible. + + background: + component: C01 + color: "#ffffff" + + signature: + accent_bar: true + logo_topbar: true + footer: true + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 0.45 + width_cm: 30.00 + height_cm: 1.90 + + content_zones: + - id: main_bullets + component: C06 + left_cm: 1.80 + top_cm: 2.80 + width_cm: 30.07 + height_cm: 14.85 + + json_schema: + required: + - titre + - bullets + optional: + - sous_titre + constraints: + bullets_min: 1 + bullets_max: 10 + bullet_l1_max: 5 + bullet_max_chars: 120 + + + # --------------------------------------------------------------------------- + # L07 — two_cols_text + # --------------------------------------------------------------------------- + two_cols_text: + id: L07 + famille: "Texte structuré" + source: "Best practice 03 p.2.2-3.2" + roles_narratifs: [contexte, comparaison, solution] + agent_hint: > + Deux colonnes de texte en parallèle. Idéal pour mise en contraste : + Objectifs / Approche, Problème / Solution, Avant / Après conceptuel. + Chaque colonne a un titre-couleur distinct. + + background: + component: C01 + color: "#ffffff" + + signature: + accent_bar: true + logo_topbar: true + footer: true + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 0.45 + width_cm: 30.00 + height_cm: 1.90 + + content_zones: + - id: col_left + component: C07 + left_cm: 1.50 + top_cm: 2.80 + width_cm: 14.80 + height_cm: 14.85 + titre_couleur: "theme.colors.primary.bright_blue" + - id: separator + type: vertical_line + x_cm: 16.93 + top_cm: 3.00 + height_cm: 14.00 + color: "theme.signature.footer.border_top_color" + width_cm: 0.03 + - id: col_right + component: C07 + left_cm: 17.50 + top_cm: 2.80 + width_cm: 14.87 + height_cm: 14.85 + titre_couleur: "theme.colors.primary.rose" + + json_schema: + required: + - titre + - left + - right + optional: + - sous_titre + constraints: + left_schema: + required: [titre, contenu] + titre_max_chars: 40 + contenu_max_chars: 500 + right_schema: + required: [titre, contenu] + titre_max_chars: 40 + contenu_max_chars: 500 + + + # --------------------------------------------------------------------------- + # L08 — key_message + # --------------------------------------------------------------------------- + key_message: + id: L08 + famille: "Texte structuré" + source: "Data Awareness V2, Best practice 02" + roles_narratifs: [accroche, solution, conclusion] + agent_hint: > + Un seul message clé, occupant presque tout le slide. Style quote. + Fond crème, guillemets décoratifs. Pas de bullets. + Utiliser pour marteler une recommandation ou un So What décisif. + + background: + component: C01 + color: "theme.colors.primary.bright_warm" + + signature: + accent_bar: false + logo_topbar: false + footer: true + + title_zone: null # pas de titre — le message est le titre + + content_zones: + - id: message_block + component: C08 + left_cm: 3.00 + top_cm: 3.50 + width_cm: 27.87 + height_cm: 12.00 + guillemet_left_cm: 3.00 + guillemet_top_cm: 3.50 + text_left_cm: 5.00 + text_top_cm: 5.00 + + json_schema: + required: + - message + optional: + - auteur + - fonction + constraints: + message_max_chars: 220 + auteur_max_chars: 60 + + + # --------------------------------------------------------------------------- + # L09 — executive_summary + # --------------------------------------------------------------------------- + executive_summary: + id: L09 + famille: "Texte structuré" + source: "Best practice 02 p.2" + roles_narratifs: [contexte, solution] + agent_hint: > + Synthèse SCR en 3 blocs. Situation = état des lieux factuel. + Complication = le problème ou la tension. Résolution = la réponse. + Standard McKinsey/BCG pour ouvrir un deck stratégique. + + background: + component: C01 + color: "#ffffff" + + signature: + accent_bar: true + logo_topbar: true + footer: true + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 0.45 + width_cm: 30.00 + height_cm: 1.90 + + content_zones: + - id: bloc_situation + component: C07 + left_cm: 1.50 + top_cm: 2.80 + width_cm: 30.87 + height_cm: 4.50 + titre_couleur: "theme.colors.primary.dark_blue" + - id: separator_1 + type: horizontal_line + left_cm: 1.50 + y_cm: 7.50 + width_cm: 30.87 + color: "theme.signature.footer.border_top_color" + - id: bloc_complication + component: C07 + left_cm: 1.50 + top_cm: 7.70 + width_cm: 30.87 + height_cm: 4.50 + titre_couleur: "theme.colors.primary.rose" + - id: separator_2 + type: horizontal_line + left_cm: 1.50 + y_cm: 12.40 + width_cm: 30.87 + color: "theme.signature.footer.border_top_color" + - id: bloc_resolution + component: C07 + left_cm: 1.50 + top_cm: 12.60 + width_cm: 30.87 + height_cm: 4.50 + titre_couleur: "theme.colors.primary.bright_blue" + + json_schema: + required: + - titre + - situation + - complication + - resolution + optional: + - sous_titre + constraints: + situation_max_chars: 300 + complication_max_chars: 300 + resolution_max_chars: 300 + + + # =========================================================================== + # FAMILLE 3 — DONNÉES & KPIs (L10-L14) + # =========================================================================== + + # --------------------------------------------------------------------------- + # L10 — kpi_grid + # --------------------------------------------------------------------------- + kpi_grid: + id: L10 + famille: "Données & KPIs" + source: "Data Awareness V2 p.4-8" + roles_narratifs: [preuve, contexte] + agent_hint: > + Grille de KPIs. Chaque carte = 1 indicateur avec sa valeur et son contexte. + La grille s'adapte automatiquement selon le nombre d'items (2-6). + Valeurs = chiffres impactants, pas des phrases. + + background: + component: C01 + color: "#ffffff" + + signature: + accent_bar: true + logo_topbar: true + footer: true + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 0.45 + width_cm: 30.00 + height_cm: 1.90 + + content_zones: + - id: kpi_cards + component: C09 + left_cm: 1.50 + top_cm: 2.80 + width_cm: 30.87 + height_cm: 14.85 + grid_rules: "component.kpi_card.grid_rules" + card_gap_cm: 0.40 + + json_schema: + required: + - titre + - items + optional: + - sous_titre + constraints: + items_min: 2 + items_max: 6 + item_schema: + required: [titre, valeur] + optional: [sous_titre, couleur] + titre_max_chars: 30 + valeur_max_chars: 12 + sous_titre_max_chars: 60 + + + # --------------------------------------------------------------------------- + # L11 — big_stat + # --------------------------------------------------------------------------- + big_stat: + id: L11 + famille: "Données & KPIs" + source: "Standard MBB" + roles_narratifs: [preuve, accroche] + agent_hint: > + Un seul chiffre énorme, centré, pour un effet de choc. + Utiliser pour un indicateur décisif qui mérite d'être seul sur le slide. + Le label contextualise en 1 ligne max. + + background: + component: C01 + color: "#ffffff" + + signature: + accent_bar: true + logo_topbar: true + footer: true + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 0.45 + width_cm: 30.00 + height_cm: 1.90 + + content_zones: + - id: stat_display + component: C10 + left_cm: 1.50 + top_cm: 2.80 + width_cm: 30.87 + height_cm: 14.85 + vertical_center: true + + json_schema: + required: + - titre + - valeur + optional: + - sous_titre + - label + - source + constraints: + valeur_max_chars: 10 + label_max_chars: 80 + source_max_chars: 60 + + + # --------------------------------------------------------------------------- + # L12 — comparison_table + # --------------------------------------------------------------------------- + comparison_table: + id: L12 + famille: "Données & KPIs" + source: "Best practice 01 p.22" + roles_narratifs: [preuve, comparaison] + agent_hint: > + Tableau comparatif structuré. Header = noms des critères ou acteurs. + Rows = lignes de données. Max 6 colonnes × 8 lignes pour rester lisible. + + background: + component: C01 + color: "#ffffff" + + signature: + accent_bar: true + logo_topbar: true + footer: true + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 0.45 + width_cm: 30.00 + height_cm: 1.90 + + content_zones: + - id: table + component: C11 + left_cm: 1.50 + top_cm: 2.80 + width_cm: 30.87 + height_cm: 14.85 + + json_schema: + required: + - titre + - headers + - rows + optional: + - sous_titre + - col_widths + - highlight_col + constraints: + headers_min: 2 + headers_max: 6 + rows_min: 1 + rows_max: 8 + header_max_chars: 30 + cell_max_chars: 60 + + + # --------------------------------------------------------------------------- + # L13 — chart_callout + # --------------------------------------------------------------------------- + chart_callout: + id: L13 + famille: "Données & KPIs" + source: "Best practice 03 (data viz)" + roles_narratifs: [preuve] + agent_hint: > + Graphique à gauche + encadré d'insight à droite. Le chart illustre, + le callout conclut. Suivre la règle BCG : le message clé se lit + sans regarder le graphique. + + background: + component: C01 + color: "#ffffff" + + signature: + accent_bar: true + logo_topbar: true + footer: true + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 0.45 + width_cm: 30.00 + height_cm: 1.90 + + content_zones: + - id: chart_area + component: C12 + left_cm: 1.50 + top_cm: 2.80 + width_cm: 19.50 + height_cm: 14.85 + - id: insight_callout + component: C13 + left_cm: 22.00 + top_cm: 4.50 + width_cm: 10.37 + height_cm: 8.00 + + json_schema: + required: + - titre + - chart_type + - data + - insight + optional: + - sous_titre + - axis_x_label + - axis_y_label + - couleurs + - titre_insight + constraints: + chart_type_values: [bar, line, pie, donut] + data_min: 2 + data_max: 8 + insight_max_chars: 200 + + + # --------------------------------------------------------------------------- + # L14 — benchmark + # --------------------------------------------------------------------------- + benchmark: + id: L14 + famille: "Données & KPIs" + source: "Data Days 2022 p.11" + roles_narratifs: [contexte, comparaison] + agent_hint: > + Benchmark concurrents. Critères en lignes, acteurs en colonnes de barres. + Max 4 acteurs. Utiliser pour positionner PR vs marché ou comparer des options. + + background: + component: C01 + color: "#ffffff" + + signature: + accent_bar: true + logo_topbar: true + footer: true + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 0.45 + width_cm: 30.00 + height_cm: 1.90 + + content_zones: + - id: benchmark_bars + component: C14 + left_cm: 1.50 + top_cm: 2.80 + width_cm: 30.87 + height_cm: 14.85 + critere_col_width_cm: 5.00 + bars_area_width_cm: 25.87 + + json_schema: + required: + - titre + - criteria + - actors + - scores + optional: + - sous_titre + - couleurs_acteurs + constraints: + criteria_min: 2 + criteria_max: 6 + actors_min: 2 + actors_max: 4 + critere_max_chars: 40 + actor_max_chars: 20 + + + # =========================================================================== + # FAMILLE 4 — FRAMEWORKS VISUELS (L15-L19) + # =========================================================================== + + # --------------------------------------------------------------------------- + # L15 — matrix_2x2 + # --------------------------------------------------------------------------- + matrix_2x2: + id: L15 + famille: "Frameworks visuels" + source: "PR Template p.20" + roles_narratifs: [contexte, solution] + agent_hint: > + Matrice 2×2 pour priorisation (impact/effort, urgence/importance…). + Chaque item = une bulle positionnée par coordonnées normalisées [0-100]. + Nommer les 2 axes clairement. Max 8 bulles pour ne pas surcharger. + + background: + component: C01 + color: "#ffffff" + + signature: + accent_bar: true + logo_topbar: true + footer: true + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 0.45 + width_cm: 30.00 + height_cm: 1.90 + + content_zones: + - id: matrix + component: C15 + left_cm: 3.50 + top_cm: 2.80 + width_cm: 26.87 + height_cm: 14.50 + axis_x_label_bottom_cm: 0.30 + axis_y_label_left_cm: 0.30 + + json_schema: + required: + - titre + - axis_x + - axis_y + - items + optional: + - sous_titre + constraints: + axis_schema: + required: [label] + optional: [min_label, max_label] + items_min: 2 + items_max: 8 + item_schema: + required: [label, x, y] + optional: [taille, couleur] + label_max_chars: 25 + x_range: [0, 100] + y_range: [0, 100] + + + # --------------------------------------------------------------------------- + # L16 — pyramid + # --------------------------------------------------------------------------- + pyramid: + id: L16 + famille: "Frameworks visuels" + source: "PR Template p.44" + roles_narratifs: [contexte, solution] + agent_hint: > + Pyramide hiérarchique. Niveau 1 = sommet (plus petit, plus rare/premium). + Niveau N = base (plus large, plus fondamental). Callouts latéraux optionnels. + 3 niveaux minimum, 5 maximum. + + background: + component: C01 + color: "#ffffff" + + signature: + accent_bar: true + logo_topbar: true + footer: true + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 0.45 + width_cm: 30.00 + height_cm: 1.90 + + content_zones: + - id: pyramid_shape + component: C16 + center_x_cm: 16.93 + top_cm: 3.00 + max_width_cm: 18.00 + height_cm: 13.50 + callout_left_area_cm: 5.00 # zone pour callouts côté gauche + callout_right_area_cm: 5.00 # zone pour callouts côté droit + + json_schema: + required: + - titre + - levels + optional: + - sous_titre + constraints: + levels_min: 3 + levels_max: 5 + level_schema: + required: [label] + optional: [description, couleur] + label_max_chars: 30 + description_max_chars: 80 + + + # --------------------------------------------------------------------------- + # L17 — circular_diagram + # --------------------------------------------------------------------------- + circular_diagram: + id: L17 + famille: "Frameworks visuels" + source: "PR Template p.46-47" + roles_narratifs: [contexte, solution] + agent_hint: > + Diagramme circulaire avec logo PR central. Idéal pour écosystème, + dimensions complémentaires, cycle vertueux. Légende numérotée à droite. + Ne pas utiliser pour montrer des proportions — utiliser chart_callout. + + background: + component: C01 + color: "#ffffff" + + signature: + accent_bar: true + logo_topbar: true + footer: true + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 0.45 + width_cm: 30.00 + height_cm: 1.90 + + content_zones: + - id: circle + component: C17 + center_x_cm: 10.00 + center_y_cm: 10.50 + outer_radius_cm: 3.50 + - id: legend + component: C17 + type: legend_only + left_cm: 16.50 + top_cm: 3.50 + width_cm: 15.87 + height_cm: 13.50 + item_height_cm: 1.60 + + json_schema: + required: + - titre + - segments + optional: + - sous_titre + constraints: + segments_min: 3 + segments_max: 6 + segment_schema: + required: [label, description] + optional: [couleur, poids] + label_max_chars: 30 + description_max_chars: 80 + + + # --------------------------------------------------------------------------- + # L18 — from_to + # --------------------------------------------------------------------------- + from_to: + id: L18 + famille: "Frameworks visuels" + source: "PR Template p.23" + roles_narratifs: [solution, contexte] + agent_hint: > + Transformation FROM → TO. Chaque paire = un changement concret. + Bloc gauche optionnel pour contexte. Bloc jaune en overlap à droite + pour le résumé de la transformation ou le bénéfice attendu. + + background: + component: C01 + color: "#ffffff" + + signature: + accent_bar: true + logo_topbar: true + footer: true + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 0.45 + width_cm: 30.00 + height_cm: 1.90 + + content_zones: + - id: description_bloc + component: C07 + optional: true + left_cm: 1.50 + top_cm: 2.80 + width_cm: 8.00 + height_cm: 12.00 + accent_top_color: "theme.colors.semantic.arrow_color" + accent_top_height: 0.12 + - id: pairs_area + component: C18 + left_cm: 10.50 + top_cm: 2.80 + width_cm: 14.00 + height_cm: 12.00 + from_label_top_cm: 0.00 # en-tête FROM/TO au-dessus des paires + from_label_text: "FROM" + to_label_text: "TO" + arrow_x_offset_cm: 7.50 + - id: summary_box + component: C13 + left_cm: 24.00 + top_cm: 5.00 + width_cm: 8.87 + height_cm: 7.00 + overlap: true # le box déborde légèrement sur pairs_area + + json_schema: + required: + - titre + - pairs + optional: + - sous_titre + - description + - titre_summary + - summary + constraints: + pairs_min: 2 + pairs_max: 5 + pair_schema: + required: [from, to] + from_max_chars: 60 + to_max_chars: 60 + description_max_chars: 200 + summary_max_chars: 150 + + + # --------------------------------------------------------------------------- + # L19 — boxes_grid + # --------------------------------------------------------------------------- + boxes_grid: + id: L19 + famille: "Frameworks visuels" + source: "PR Template p.17" + roles_narratifs: [preuve, contexte] + agent_hint: > + Matrice analytique dense. Colonne label (wheat yellow) + colonne KPI + + 3-4 colonnes de contenu. Pour analyse multi-critères, roadmap détaillée, + ou comparaison de scenarios. + + background: + component: C01 + color: "#ffffff" + + signature: + accent_bar: true + logo_topbar: true + footer: true + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 0.45 + width_cm: 30.00 + height_cm: 1.90 + + content_zones: + - id: grid + component: C11 + type: boxes_grid_variant # variante avec col label wheat + col KPI + left_cm: 1.50 + top_cm: 2.50 + width_cm: 30.87 + height_cm: 15.15 + label_col_width_cm: 4.50 + kpi_col_width_cm: 2.00 + header_height_cm: 0.70 + row_height_cm: 2.80 + + json_schema: + required: + - titre + - columns + - rows + optional: + - sous_titre + constraints: + columns_min: 3 + columns_max: 5 + rows_min: 2 + rows_max: 5 + column_max_chars: 25 + row_schema: + required: [label, kpi, contents] + label_max_chars: 30 + kpi_max_chars: 8 + content_max_chars: 80 + + + # =========================================================================== + # FAMILLE 5 — PROCESS & ROADMAP (L20-L24) + # =========================================================================== + + # --------------------------------------------------------------------------- + # L20 — numbered_steps + # --------------------------------------------------------------------------- + numbered_steps: + id: L20 + famille: "Process & roadmap" + source: "Data Awareness V2 p.10" + roles_narratifs: [solution, methodologie] + agent_hint: > + Étapes numérotées verticalement. Idéal pour méthodologie, bonnes pratiques, + checklist d'implémentation. Titre de step = verbe d'action. + Description = résultat attendu, pas la procédure. + + background: + component: C01 + color: "#ffffff" + + signature: + accent_bar: true + logo_topbar: true + footer: true + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 0.45 + width_cm: 30.00 + height_cm: 1.90 + + content_zones: + - id: steps + component: C19 + left_cm: 1.50 + top_cm: 2.80 + width_cm: 30.87 + height_cm: 14.85 + badge_left_cm: 0.00 + title_left_cm: 1.20 + desc_left_cm: 1.20 + + json_schema: + required: + - titre + - steps + optional: + - sous_titre + constraints: + steps_min: 2 + steps_max: 6 + step_schema: + required: [numero, titre] + optional: [description] + titre_max_chars: 50 + description_max_chars: 120 + + + # --------------------------------------------------------------------------- + # L21 — process_arrow + # --------------------------------------------------------------------------- + process_arrow: + id: L21 + famille: "Process & roadmap" + source: "Best practice 03" + roles_narratifs: [solution, next-steps] + agent_hint: > + Chevrons horizontaux pour une roadmap phasée. Étape active = dark blue. + Dernier chevron = triangle fermé (→). Bullets sous chaque phase. + Max 5 étapes. Pas de numéro dans les labels. + + background: + component: C01 + color: "#ffffff" + + signature: + accent_bar: true + logo_topbar: true + footer: true + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 0.45 + width_cm: 30.00 + height_cm: 1.90 + + content_zones: + - id: chevrons + component: C20 + left_cm: 0.50 + top_cm: 2.90 + width_cm: 32.87 + chevron_height_cm: 1.00 + bullets_top_cm: 4.20 # bullets commencent ici sous les chevrons + bullets_height_cm: 11.00 + + json_schema: + required: + - titre + - phases + optional: + - sous_titre + constraints: + phases_min: 2 + phases_max: 5 + phase_schema: + required: [label] + optional: [duree, actif, bullets, terminal] + label_max_chars: 20 + duree_max_chars: 15 + bullets_max: 4 + bullet_max_chars: 60 + + + # --------------------------------------------------------------------------- + # L22 — gantt_timeline + # --------------------------------------------------------------------------- + gantt_timeline: + id: L22 + famille: "Process & roadmap" + source: "Best practice 03 p.2.1" + roles_narratifs: [next-steps, solution] + agent_hint: > + Gantt simplifié. Workstreams à gauche (optionnel), barres colorées sur + la timeline. Une couleur par workstream. Dates au format YYYY-MM. + Idéal pour présenter une roadmap opérationnelle sur 6-12 mois. + + background: + component: C01 + color: "#ffffff" + + signature: + accent_bar: true + logo_topbar: true + footer: true + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 0.45 + width_cm: 30.00 + height_cm: 1.90 + + content_zones: + - id: gantt + component: C21 + left_cm: 1.50 + top_cm: 2.80 + width_cm: 30.87 + height_cm: 14.85 + label_col_width_cm: 5.50 # colonne labels workstream (optionnelle) + header_height_cm: 0.60 # ligne des mois + workstream_height_cm: 2.80 # hauteur par workstream (2 lignes max) + label_optional: true + + json_schema: + required: + - titre + - period + - workstreams + optional: + - sous_titre + constraints: + period_schema: + required: [start, end] + format: "YYYY-MM" + workstreams_min: 1 + workstreams_max: 5 + workstream_schema: + optional: [label] + required: [tasks] + label_max_chars: 30 + tasks_min: 1 + tasks_max: 4 + task_schema: + required: [start, end] + optional: [label, couleur, row] + + + # --------------------------------------------------------------------------- + # L23 — yearly_timeline + # --------------------------------------------------------------------------- + yearly_timeline: + id: L23 + famille: "Process & roadmap" + source: "PR Template p.37" + roles_narratifs: [contexte] + agent_hint: > + Timeline horizontale de jalons annuels. Idéal pour historique ou prospective. + Jalon actif en rose. Labels années au-dessus, descriptions courtes en dessous. + Ne pas mettre plus de 6 jalons pour garder la lisibilité. + + background: + component: C01 + color: "#ffffff" + + signature: + accent_bar: true + logo_topbar: true + footer: true + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 0.45 + width_cm: 30.00 + height_cm: 1.90 + + content_zones: + - id: timeline + component: C22 + left_cm: 1.50 + top_cm: 2.80 + width_cm: 30.87 + height_cm: 14.85 + axis_y_cm: 10.00 # position de l'axe horizontal + milestones_spacing: auto # espacement calculé automatiquement + + json_schema: + required: + - titre + - milestones + optional: + - sous_titre + constraints: + milestones_min: 3 + milestones_max: 6 + milestone_schema: + required: [annee, label] + optional: [description, actif] + annee_max_chars: 10 + label_max_chars: 40 + description_max_chars: 80 + + + # --------------------------------------------------------------------------- + # L24 — phases_timeline + # --------------------------------------------------------------------------- + phases_timeline: + id: L24 + famille: "Process & roadmap" + source: "Data Days 2022 p.7" + roles_narratifs: [solution, methodologie] + agent_hint: > + Timeline en phases horizontales contiguës. Largeur proportionnelle à la durée. + Chaque phase = un bandeau coloré + période + livrables en bullets. + Pour projets séquentiels avec chevauchements possibles. + + background: + component: C01 + color: "#ffffff" + + signature: + accent_bar: true + logo_topbar: true + footer: true + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 0.45 + width_cm: 30.00 + height_cm: 1.90 + + content_zones: + - id: phase_bars + component: C22 + type: phases_variant # variante : bandes contiguës horizontales + left_cm: 1.50 + top_cm: 2.80 + width_cm: 30.87 + phase_bar_height_cm: 0.65 + period_row_height_cm: 0.50 + bullets_area_top_cm: 4.20 + bullets_area_height_cm: 10.00 + + json_schema: + required: + - titre + - phases + optional: + - sous_titre + constraints: + phases_min: 2 + phases_max: 5 + phase_schema: + required: [label, periode] + optional: [items] + label_max_chars: 15 + periode_max_chars: 15 + items_max: 4 + item_max_chars: 60 + + + # =========================================================================== + # FAMILLE 6 — ACTEURS & DÉCISION (L25-L28) + # =========================================================================== + + # --------------------------------------------------------------------------- + # L25 — org_chart + # --------------------------------------------------------------------------- + org_chart: + id: L25 + famille: "Acteurs & décision" + source: "Best practice 03 (Operating model)" + roles_narratifs: [acteurs, contexte] + agent_hint: > + Organigramme hiérarchique. Niveau 1 = leadership (dark blue). + Nœuds distribués automatiquement. Max 4 niveaux, max 20 nœuds total. + Utile pour présenter le modèle opérationnel Data Governance. + + background: + component: C01 + color: "#ffffff" + + signature: + accent_bar: true + logo_topbar: true + footer: true + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 0.45 + width_cm: 30.00 + height_cm: 1.90 + + content_zones: + - id: org_tree + component: C23 + left_cm: 1.50 + top_cm: 2.80 + width_cm: 30.87 + height_cm: 14.85 + layout_algorithm: top_down # distribution automatique top-down + node_min_width_cm: 2.50 + node_height_cm: 0.65 + level_gap_cm: 1.20 + + json_schema: + required: + - titre + - root + optional: + - sous_titre + constraints: + max_total_nodes: 20 + max_levels: 4 + node_schema: + required: [label] + optional: [sous_label, children] + label_max_chars: 35 + sous_label_max_chars: 30 + + + # --------------------------------------------------------------------------- + # L26 — raci_table + # --------------------------------------------------------------------------- + raci_table: + id: L26 + famille: "Acteurs & décision" + source: "Standard project management" + roles_narratifs: [acteurs, solution] + agent_hint: > + Matrice RACI. Tâches en lignes, acteurs en colonnes header (R/A/C/I). + R = Responsible (fait), A = Accountable (décide), C = Consulté, I = Informé. + Chaque tâche a exactement 1 R et 1 A. Pas de cellule vide obligatoire pour C/I. + + background: + component: C01 + color: "#ffffff" + + signature: + accent_bar: true + logo_topbar: true + footer: true + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 0.45 + width_cm: 30.00 + height_cm: 1.90 + + content_zones: + - id: raci_grid + component: C24 + left_cm: 1.50 + top_cm: 2.80 + width_cm: 30.87 + height_cm: 14.85 + task_col_width_cm: 8.00 + role_col_width_cm: auto # distribué sur le reste + + json_schema: + required: + - titre + - roles + - tasks + optional: + - sous_titre + constraints: + roles_min: 3 + roles_max: 6 + tasks_min: 2 + tasks_max: 8 + role_max_chars: 25 + task_schema: + required: [label, raci] + label_max_chars: 50 + raci_values: ["R", "A", "C", "I", ""] + + + # --------------------------------------------------------------------------- + # L27 — decision_tree + # --------------------------------------------------------------------------- + decision_tree: + id: L27 + famille: "Acteurs & décision" + source: "Best practice 02 p.16" + roles_narratifs: [decision, solution] + agent_hint: > + Arbre de décision binaire. Question centrale → 2 branches YES/NO + → 2 options par branche. Branche recommandée en rose. + Pour structurer un arbitrage stratégique avec ses implications concrètes. + + background: + component: C01 + color: "#ffffff" + + signature: + accent_bar: true + logo_topbar: true + footer: true + + title_zone: + component: C02 + left_cm: 1.80 + top_cm: 0.45 + width_cm: 30.00 + height_cm: 1.90 + + content_zones: + - id: decision_tree + component: C25 + left_cm: 1.50 + top_cm: 2.80 + width_cm: 30.87 + height_cm: 14.85 + question_node: + width_cm: 6.00 + height_cm: 3.00 + left_cm: 1.50 # relatif à la zone + center_y_cm: 7.40 + branch_node: + width_cm: 5.50 + height_cm: 2.50 + option_node: + width_cm: 5.00 + height_cm: 1.60 + + json_schema: + required: + - titre + - question + - branches + optional: + - sous_titre + constraints: + question_max_chars: 80 + branches_schema: + required: [yes, no] + branch_schema: + required: [label, options] + label_max_chars: 60 + options_min: 1 + options_max: 2 + option_max_chars: 60 + + + # --------------------------------------------------------------------------- + # L28 — recommendation_card + # --------------------------------------------------------------------------- + recommendation_card: + id: L28 + famille: "Acteurs & décision" + source: "Best practice 02 p.14 (Fewer.Better)" + roles_narratifs: [solution, decision, next-steps] + agent_hint: > + Carte de recommandation unique et cadrée. Sidebar gauche jaune = + numéro + titre court + résumé + CTA. Corps droit = headline + bullets détaillés. + Format "Fewer. Better." — une recommandation précise par slide. + + background: + component: C01 + color: "#ffffff" + + signature: + accent_bar: false # la sidebar remplace la barre d'accent + logo_topbar: false + footer: true + + title_zone: null # le titre est dans la sidebar (numero + titre) + + content_zones: + - id: sidebar + component: C26 + left_cm: 0.00 + top_cm: 0.00 + width_cm: 8.00 + height_cm: 18.35 # pleine hauteur (hors footer) + - id: main_header + type: header_band + left_cm: 8.50 + top_cm: 1.00 + width_cm: 24.87 + height_cm: 1.20 + background: "theme.colors.primary.dark_blue" + text_color: "#ffffff" + font: "theme.typography.body" + size_pt: 11 + bold: true + letter_spacing: 0.15 # style "FEWER. BETTER." + - id: main_content + component: C06 + left_cm: 8.50 + top_cm: 2.50 + width_cm: 24.37 + height_cm: 14.85 + + json_schema: + required: + - numero + - titre + - headline + - bullets + optional: + - subtitle + - resume + - cta + constraints: + numero_type: integer + numero_min: 1 + numero_max: 9 + titre_max_chars: 30 + headline_max_chars: 40 + bullets_min: 2 + bullets_max: 6 + bullet_max_chars: 100 + resume_max_chars: 120 + cta_max_chars: 30 diff --git a/archive/v1_pipeline/prompt_injection.py b/archive/v1_pipeline/prompt_injection.py new file mode 100644 index 0000000..9e244f7 --- /dev/null +++ b/archive/v1_pipeline/prompt_injection.py @@ -0,0 +1,626 @@ +""" +prompt_injection.py — Sliding Pipeline · Pernod Ricard +======================================================= +Génère les blocs de contraintes injectés dans les prompts des agents. + +Ce script lit les 3 YAML (theme, components, layouts) et produit : + 1. agent_constraints.md — fichier de référence complet (debug/doc) + 2. prompt_the_designer_injected.md — prompt Designer avec blocs remplis + 3. prompt_the_encoder_injected.md — prompt Encoder avec schémas remplis + +Usage : + python prompt_injection.py + python prompt_injection.py --theme theme.yaml --components components.yaml + --layouts layouts.yaml + --designer prompt_the_designer.md + --encoder prompt_the_encoder.md + +Les fichiers injectés sont prêts à être copiés dans Mistral Studio. +""" + +import argparse +import os +import sys +from pathlib import Path + +import yaml + + +# ───────────────────────────────────────────────────────────────────────────── +# DONNÉES ÉDITORIALES (non générables depuis les YAML — savoirs métier) +# ───────────────────────────────────────────────────────────────────────────── + +SEQUENCING_RULES = """### Règles de fluidité + +- Maximum 2 layouts texte consécutifs : `default_bullets`, `two_cols_text` +- Maximum 2 tableaux consécutifs : `comparison_table`, `benchmark`, `raci_table`, `boxes_grid` +- Après 3 slides denses → intercaler une respiration : `big_stat`, `key_message`, `section_divider` +- Jamais 2 `section_divider` consécutifs +- Le dernier slide de contenu avant `end_slide` doit être narratif (pas un tableau, pas un gantt) + +### Règles de choix de layout + +- 1 seul chiffre décisif → `big_stat` (jamais `kpi_grid` avec 1 item) +- 2 à 6 indicateurs chiffrés → `kpi_grid` +- Transformation conceptuelle (avant/après) → `from_to` (pas `two_cols_text`) +- Plus de 4 étapes avec timing → `process_arrow` ou `phases_timeline` (pas `numbered_steps`) +- Recommandation unique et précise → `recommendation_card` +- Données à comparer sur plusieurs critères avec plusieurs acteurs → `benchmark` +- Données à comparer en tableau structuré → `comparison_table` +- Si le contenu ne rentre dans aucun layout spécialisé → `default_bullets` +- Citation ou message à marteler seul → `key_message` +- Organigramme de gouvernance → `org_chart` +- Responsabilités par rôle → `raci_table` + +### Règles de quantité + +- Présentation 20 min → 10 à 15 slides max +- Présentation 10 min → 6 à 10 slides max +- Fusionner si > 15 slides : regrouper les slides proches thématiquement +- Un `###` du Markdown avec un seul chiffre fort → envisager `big_stat` séparé +- Un `##` du Markdown = 1 `section_divider` (sauf présentation < 6 slides)""" + + +NARRATIVE_PATTERNS = """### Pattern "Problem → Solution → Proof" +Adapté aux présentations de recommandation stratégique. +``` +cover_split +executive_summary (synthèse SCR dès le début) +section_divider ("Le problème") +big_stat (chiffre choc) +default_bullets ou comparison_table +section_divider ("Notre réponse") +from_to ou numbered_steps +kpi_grid ou chart_callout (preuve que ça marche) +recommendation_card (ce qu'on demande) +end_slide +``` + +### Pattern "Roadmap Deck" +Adapté aux présentations de planification / lancement de projet. +``` +cover_split +executive_summary (où on va et pourquoi) +kpi_grid (état des lieux chiffré) +phases_timeline ou gantt_timeline +numbered_steps (comment on s'organise) +org_chart ou raci_table (qui fait quoi) +recommendation_card (décisions à prendre) +end_slide +``` + +### Pattern "Data Storytelling" +Adapté aux présentations de revue de performance ou data governance. +``` +cover_split +big_stat (chiffre choc d'entrée) +default_bullets (contexte et enjeux) +kpi_grid (panorama des indicateurs) +chart_callout (analyse d'un graphique clé) +from_to (implication / transformation attendue) +yearly_timeline (historique ou prospective) +end_slide +``` + +### Pattern "Executive Briefing" +Adapté aux présentations courtes (< 10 slides) pour un CODIR. +``` +cover_split +executive_summary +key_message (le So What en 1 slide) +kpi_grid ou big_stat +recommendation_card +end_slide +``` + +### Règles d'assemblage des patterns + +- Les patterns sont des points de départ, pas des contraintes rigides +- Hybrider 2 patterns est possible si le contenu le justifie +- Toujours préserver : cover_split en premier, end_slide en dernier +- Les section_dividers sont optionnels pour les patterns courts (< 8 slides)""" + + +# ───────────────────────────────────────────────────────────────────────────── +# GÉNÉRATEURS DE BLOCS +# ───────────────────────────────────────────────────────────────────────────── + +def generate_layouts_catalogue(layouts: dict) -> str: + """ + Génère le bloc LAYOUTS_CATALOGUE pour le prompt du Designer. + Format compact, optimisé pour la lecture LLM. + """ + lines = [] + current_famille = None + + for layout_name, cfg in layouts.items(): + famille = cfg.get("famille", "") + if famille != current_famille: + lines.append(f"\n#### {famille}") + current_famille = famille + + layout_id = cfg.get("id", "") + roles = " / ".join(cfg.get("roles_narratifs", [])) + hint = cfg.get("agent_hint", "").strip().replace("\n", " ").replace(" ", " ") + # Tronquer le hint à 120 caractères pour rester compact + if len(hint) > 120: + hint = hint[:117] + "..." + + lines.append(f"\n`{layout_name}` ({layout_id}) — Rôles : {roles}") + lines.append(f" → {hint}") + + return "\n".join(lines) + + +def generate_yaml_schemas(layouts: dict) -> str: + """ + Génère le bloc YAML_SCHEMAS pour le prompt de l'Encoder. + Pour chaque layout : champs requis / optionnels avec types et contraintes. + """ + lines = [] + + for layout_name, cfg in layouts.items(): + layout_id = cfg.get("id", "") + schema = cfg.get("json_schema", {}) + constraints = cfg.get("constraints", {}) + + if not schema: + continue + + lines.append(f"\n### `{layout_name}` ({layout_id})") + + # Champs requis + required = schema.get("required", []) + if required: + lines.append(f"**Requis :** {', '.join(required)}") + + # Champs optionnels + optional = schema.get("optional", []) + if optional: + lines.append(f"**Optionnels :** {', '.join(optional)}") + + # Contraintes importantes + constraint_lines = [] + for key, val in constraints.items(): + if isinstance(val, dict): + # Schéma imbriqué — on extrait les infos clés + sub_req = val.get("required", []) + sub_opt = val.get("optional", []) + sub_min = val.get("min_items") or val.get(f"{key}_min") + sub_max = val.get("max_items") or val.get(f"{key}_max") + if sub_req or sub_opt: + parts = [] + if sub_req: + parts.append(f"requis: {', '.join(str(x) for x in sub_req)}") + if sub_opt: + parts.append(f"optionnels: {', '.join(str(x) for x in sub_opt)}") + constraint_lines.append(f" {key} : {' | '.join(parts)}") + elif key.endswith("_min") or key.endswith("_max"): + field = key.rsplit("_", 1)[0] + bound = key.rsplit("_", 1)[1] + constraint_lines.append(f" {field} : {bound} = {val}") + elif key.endswith("_max_chars"): + field = key.replace("_max_chars", "") + constraint_lines.append(f" {field} : max {val} caractères") + elif key == "items_min": + constraint_lines.append(f" items : min {val}") + elif key == "items_max": + constraint_lines.append(f" items : max {val}") + + if constraint_lines: + lines.append("**Contraintes :**") + lines.extend(constraint_lines) + + # Exemple YAML minimal + lines.append("**Exemple minimal :**") + lines.append("```yaml") + lines.append(f"layout: {layout_name}") + lines.append(f'titre: "Votre titre affirmatif"') + + # Génère quelques champs d'exemple selon le layout + example_fields = _generate_example_fields(layout_name, required, constraints) + lines.extend(example_fields) + + lines.append("```") + + return "\n".join(lines) + + +def _generate_example_fields(layout_name: str, required: list, constraints: dict) -> list: + """Génère des champs d'exemple YAML pour un layout donné.""" + examples = { + "cover_split": ['sous_titre: "Présentation au CODIR — juin 2026"'], + "section_divider": ["numero_section: 1"], + "agenda": [ + "items:", + ' - numero: 1', + ' titre: "Contexte et enjeux"', + ' - numero: 2', + ' titre: "Notre proposition"', + ], + "content_marker": ["current_index: 2"], + "end_slide": [ + 'message: "Merci pour votre attention"', + "next_steps:", + ' - texte: "Valider le modèle — juillet"', + ' niveau: 1', + ], + "default_bullets": [ + "bullets:", + ' - texte: "Premier argument clé"', + " niveau: 1", + ' - texte: "Détail ou preuve"', + " niveau: 2", + ], + "two_cols_text": [ + "left:", + ' titre: "Titre colonne gauche"', + ' contenu: "Texte de la colonne gauche..."', + "right:", + ' titre: "Titre colonne droite"', + ' contenu: "Texte de la colonne droite..."', + ], + "key_message": ['message: "Le message clé en une phrase forte."'], + "executive_summary": [ + 'situation: "État des lieux factuel..."', + 'complication: "Le problème ou la tension..."', + 'resolution: "La réponse proposée..."', + ], + "kpi_grid": [ + "items:", + ' - titre: "Indicateur 1"', + ' valeur: "85%"', + ' sous_titre: "Contexte de la valeur"', + ' - titre: "Indicateur 2"', + ' valeur: "+25%"', + ], + "big_stat": [ + 'valeur: "2 400"', + 'label: "jours/homme de réconciliation par an"', + 'source: "Estimation interne 2026"', + ], + "comparison_table": [ + "headers:", + ' - "Critère"', + ' - "Option A"', + ' - "Option B"', + "rows:", + ' - ["Coût", "Élevé", "Moyen"]', + ' - ["Délai", "3 mois", "6 mois"]', + ], + "chart_callout": [ + "chart_type: bar", + "data:", + ' - label: "T1"', + " valeur: 40", + ' - label: "T2"', + " valeur: 65", + 'insight: "La croissance s\'accélère au T2 grâce au pilote."', + ], + "benchmark": [ + "criteria:", + ' - "Coût"', + ' - "Délai"', + "actors:", + ' - "PR"', + ' - "Concurrent A"', + "scores:", + " - [80, 60]", + " - [70, 85]", + ], + "matrix_2x2": [ + "axis_x:", + ' label: "Effort"', + "axis_y:", + ' label: "Impact"', + "items:", + ' - label: "Initiative A"', + " x: 20", + " y: 80", + " taille: 3", + ], + "pyramid": [ + "levels:", + ' - label: "Vision"', + ' description: "Callout explicatif optionnel"', + ' - label: "Stratégie"', + ' - label: "Opérations"', + ], + "circular_diagram": [ + "segments:", + ' - label: "Segment 1"', + ' description: "Description courte"', + ' - label: "Segment 2"', + ' description: "Description courte"', + ' - label: "Segment 3"', + ' description: "Description courte"', + ], + "from_to": [ + "pairs:", + ' - from: "Situation actuelle"', + ' to: "Situation cible"', + ' - from: "Processus manuel"', + ' to: "Processus automatisé"', + ], + "boxes_grid": [ + "columns:", + ' - "Colonne 1"', + ' - "Colonne 2"', + "rows:", + " - label: \"Ligne A\"", + ' kpi: "xx%"', + ' contents: ["Contenu 1", "Contenu 2"]', + ], + "numbered_steps": [ + "steps:", + " - numero: 1", + ' titre: "Première étape"', + ' description: "Ce que ça implique concrètement"', + " - numero: 2", + ' titre: "Deuxième étape"', + ], + "process_arrow": [ + "phases:", + ' - label: "Phase 1"', + ' duree: "Juin"', + " actif: false", + ' bullets: ["Livrable A", "Livrable B"]', + ' - label: "Phase 2"', + ' duree: "Juil-Sept"', + " actif: true", + ], + "gantt_timeline": [ + "period:", + ' start: "2026-06"', + ' end: "2026-12"', + "workstreams:", + ' - label: "Workstream 1"', + " tasks:", + ' - start: "2026-06"', + ' end: "2026-08"', + ], + "yearly_timeline": [ + "milestones:", + ' - annee: "2024"', + ' label: "Lancement du projet"', + ' - annee: "2025"', + ' label: "Pilote Suède"', + " actif: true", + ' - annee: "2026"', + ' label: "Déploiement nordique"', + ], + "phases_timeline": [ + "phases:", + ' - label: "PREP"', + ' periode: "Juin"', + ' items: ["Brief équipe", "Setup outil"]', + ' - label: "PROD"', + ' periode: "Juil-Oct"', + ' items: ["Développement", "Tests"]', + ], + "org_chart": [ + "root:", + ' label: "Data Gov Leader"', + " children:", + ' - label: "Data Owner Finance"', + ' children:', + ' - label: "Data Steward"', + ' - label: "Data Owner Supply"', + ], + "raci_table": [ + "roles:", + ' - "Data Owner"', + ' - "Data Steward"', + ' - "IT"', + "tasks:", + ' - label: "Définir les règles qualité"', + ' raci: ["A", "R", "C"]', + ' - label: "Exécuter les contrôles"', + ' raci: ["A", "R", "I"]', + ], + "decision_tree": [ + 'question: "Faut-il déployer le pilote en Suède ?"', + "branches:", + " yes:", + ' label: "Engagement DG confirmé"', + ' options: ["Démarrer en juin", "Allouer 0.5 ETP"]', + " no:", + ' label: "Engagement DG manquant"', + ' options: ["Reporter à septembre", "Choisir une autre filiale"]', + ], + "recommendation_card": [ + "numero: 1", + ' headline: "TROIS DÉCISIONS AVANT FIN JUIN"', + "bullets:", + ' - texte: "Valider le modèle avec les DG locaux"', + " niveau: 1", + ' - texte: "Nommer les Data Owners"', + " niveau: 1", + ' - texte: "Allouer 0.5 ETP par filiale"', + " niveau: 1", + 'cta: "Décider en réunion du 30 juin"', + ], + } + return examples.get(layout_name, []) + + +# ───────────────────────────────────────────────────────────────────────────── +# GÉNÉRATION DES FICHIERS +# ───────────────────────────────────────────────────────────────────────────── + +def generate_agent_constraints(layouts: dict, output_path: str): + """Génère agent_constraints.md — fichier de référence complet.""" + lines = [ + "# agent_constraints.md", + "# Généré automatiquement par prompt_injection.py", + "# Ne pas modifier manuellement.\n", + "=" * 70, + "## SECTION A — CATALOGUE DES LAYOUTS (pour The Designer)", + "=" * 70, + generate_layouts_catalogue(layouts), + "\n" + "=" * 70, + "## SECTION B — RÈGLES DE SÉQUENÇAGE (pour The Designer)", + "=" * 70, + SEQUENCING_RULES, + "\n" + "=" * 70, + "## SECTION C — PATTERNS NARRATIFS (pour The Designer)", + "=" * 70, + NARRATIVE_PATTERNS, + "\n" + "=" * 70, + "## SECTION D — SCHÉMAS YAML (pour The Encoder)", + "=" * 70, + generate_yaml_schemas(layouts), + ] + + with open(output_path, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + + print(f"✓ agent_constraints.md généré : {output_path}") + + +def inject_prompt(template_path: str, layouts: dict, output_path: str): + """ + Lit un template de prompt, remplace les placeholders {{...}} + et écrit le prompt injecté. + """ + with open(template_path, encoding="utf-8") as f: + template = f.read() + + replacements = { + "{{LAYOUTS_CATALOGUE}}": generate_layouts_catalogue(layouts), + "{{SEQUENCING_RULES}}": SEQUENCING_RULES, + "{{NARRATIVE_PATTERNS}}": NARRATIVE_PATTERNS, + "{{YAML_SCHEMAS}}": generate_yaml_schemas(layouts), + } + + injected = template + for placeholder, content in replacements.items(): + injected = injected.replace(placeholder, content) + + with open(output_path, "w", encoding="utf-8") as f: + f.write(injected) + + # Vérifie qu'il ne reste pas de placeholders non résolus + remaining = [p for p in replacements if p in injected] + if remaining: + print(f" ⚠ Placeholders non résolus dans {output_path} : {remaining}") + else: + print(f"✓ Prompt injecté : {output_path}") + + +# ───────────────────────────────────────────────────────────────────────────── +# RAPPORT DE COHÉRENCE +# ───────────────────────────────────────────────────────────────────────────── + +def check_coherence(layouts: dict, components: dict): + """Vérifie la cohérence entre layouts et components.""" + print("\n── Rapport de cohérence ──────────────────────────────────") + + layout_to_components = components.get("layout_to_components", {}) + all_comp_ids = {v["id"] for v in components.get("components", {}).values()} + + errors = 0 + for layout_name in layouts: + if layout_name not in layout_to_components: + print(f" ⚠ Layout '{layout_name}' absent de layout_to_components") + errors += 1 + else: + comp_ids = layout_to_components[layout_name] + for cid in comp_ids: + if cid not in all_comp_ids: + print(f" ⚠ Composant '{cid}' (layout {layout_name}) introuvable") + errors += 1 + + if errors == 0: + print(f" ✓ {len(layouts)} layouts × {len(all_comp_ids)} composants — aucune erreur") + else: + print(f" ✗ {errors} erreur(s) détectée(s)") + print() + + +# ───────────────────────────────────────────────────────────────────────────── +# CLI +# ───────────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="Sliding prompt_injection — Génère les prompts enrichis des agents") + parser.add_argument("--theme", + default="theme.yaml") + parser.add_argument("--components", + default="components.yaml") + parser.add_argument("--layouts", + default="layouts.yaml") + parser.add_argument("--designer", + default="prompt_the_designer.md") + parser.add_argument("--encoder", + default="prompt_the_encoder.md") + parser.add_argument("--output-dir", + default=".", + help="Dossier de sortie des fichiers générés") + args = parser.parse_args() + + # Vérification des fichiers sources + for path in [args.theme, args.components, args.layouts, + args.designer, args.encoder]: + if not os.path.exists(path): + print(f"✗ Fichier introuvable : {path}") + sys.exit(1) + + out = Path(args.output_dir) + out.mkdir(parents=True, exist_ok=True) + + # Chargement des YAML + with open(args.theme, encoding="utf-8") as f: + theme = yaml.safe_load(f) + with open(args.components, encoding="utf-8") as f: + components_data = yaml.safe_load(f) + with open(args.layouts, encoding="utf-8") as f: + layouts_data = yaml.safe_load(f) + + layouts = layouts_data["layouts"] + components = components_data + + print(f"\n── Sliding prompt_injection ──────────────────────────────") + print(f" Layouts : {len(layouts)}") + print(f" Composants: {len(components.get('components', {}))}") + print() + + # Rapport de cohérence + check_coherence(layouts, components) + + # Génération des fichiers + generate_agent_constraints( + layouts, + str(out / "agent_constraints.md") + ) + + inject_prompt( + args.designer, + layouts, + str(out / "prompt_the_designer_injected.md") + ) + + inject_prompt( + args.encoder, + layouts, + str(out / "prompt_the_encoder_injected.md") + ) + + print() + print("── Fichiers générés ──────────────────────────────────────") + for f in ["agent_constraints.md", + "prompt_the_designer_injected.md", + "prompt_the_encoder_injected.md"]: + path = out / f + if path.exists(): + size = path.stat().st_size + print(f" {f} ({size:,} octets)") + + print() + print("✓ Injection terminée. Copiez les prompts _injected.md") + print(" dans le champ 'Instructions' de chaque agent Mistral Studio.") + + +if __name__ == "__main__": + main() diff --git a/archive/v1_pipeline/prompt_the_designer.md b/archive/v1_pipeline/prompt_the_designer.md new file mode 100644 index 0000000..c6a0429 --- /dev/null +++ b/archive/v1_pipeline/prompt_the_designer.md @@ -0,0 +1,194 @@ +# THE DESIGNER +# Sliding Pipeline — Pernod Ricard +# Mistral Large · Temperature 0.4 · Format : Texte + +## RÔLE + +Tu es The Designer. Tu reçois un Markdown narratif approuvé par Bastien et tu produis un **plan de présentation** : la liste ordonnée des slides avec le layout choisi et les données à y mettre. + +Tu prends des décisions **éditoriales et visuelles** : quel layout rend ce contenu le plus lisible ? L'enchaînement des slides est-il fluide ? Y a-t-il trop de slides texte consécutifs ? Un chiffre mérite-t-il son propre slide ? + +Tu ne produis pas de YAML. Tu ne remplis pas de schéma. Tu produis un **plan texte structuré**, lisible par un humain et précis pour être encodé par l'agent suivant. + +--- + +## CE QUE TU REÇOIS + +Un Markdown avec : +- `#` = titre de la présentation +- `##` = sections (parties) +- `###` = slides individuels avec leur contenu brut + +--- + +## CE QUE TU PRODUIS + +Un plan de présentation slide par slide, dans ce format exact : + +``` +SLIDE [N] — [NOM_LAYOUT] + Titre : "[titre affirmatif]" + [champ 1] : [valeur ou description du contenu] + [champ 2] : [valeur ou description du contenu] + → [Justification éditoriale en 1 ligne] +``` + +**Règles de format :** +- Chaque slide commence par `SLIDE N — NOM_LAYOUT` (N = numéro, NOM_LAYOUT = nom exact du layout) +- Les champs suivent immédiatement, indentés de 2 espaces +- Le `→` en fin de slide = justification de ton choix éditorial (obligatoire) +- Pas de YAML, pas d'accolades, pas de crochets JSON +- Les valeurs textuelles longues → résumées en 1-2 lignes, pas copiées intégralement + +--- + +## LES 28 LAYOUTS DISPONIBLES + +[CE BLOC EST GÉNÉRÉ AUTOMATIQUEMENT PAR prompt_injection.py] +[NE PAS MODIFIER MANUELLEMENT] + +{{LAYOUTS_CATALOGUE}} + +--- + +## RÈGLES DE SÉQUENÇAGE + +[CE BLOC EST GÉNÉRÉ AUTOMATIQUEMENT PAR prompt_injection.py] +[NE PAS MODIFIER MANUELLEMENT] + +{{SEQUENCING_RULES}} + +--- + +## PATTERNS NARRATIFS + +[CE BLOC EST GÉNÉRÉ AUTOMATIQUEMENT PAR prompt_injection.py] +[NE PAS MODIFIER MANUELLEMENT] + +{{NARRATIVE_PATTERNS}} + +--- + +## RÈGLES ABSOLUES + +**Sur les layouts :** +- Utilise uniquement les layouts listés dans le catalogue ci-dessus +- Le premier slide est TOUJOURS `cover_split` +- Le dernier slide est TOUJOURS `end_slide` +- Les `##` du Markdown → `section_divider` (sauf si la présentation est courte de moins de 6 slides) +- Un `section_divider` n'a jamais de contenu — il ne fait que nommer la section + +**Sur la fluidité :** +- Maximum 2 layouts texte consécutifs (`default_bullets`, `two_cols_text`) +- Jamais 2 tableaux consécutifs (`comparison_table`, `benchmark`, `raci_table`) +- Après 3 slides denses → intercaler une respiration (`big_stat`, `key_message`, `section_divider`) +- Finir sur du sens, jamais sur des données — le dernier slide de contenu avant `end_slide` est narratif + +**Sur les choix de layout :** +- 1 chiffre décisif seul → `big_stat` (pas `kpi_grid` avec 1 item) +- 2 à 6 indicateurs → `kpi_grid` +- Transformation conceptuelle → `from_to` (pas `two_cols_text`) +- Plus de 4 étapes séquentielles avec timing → `process_arrow` ou `phases_timeline` +- Recommandation unique et précise → `recommendation_card` +- Données à comparer sur plusieurs critères → `comparison_table` ou `benchmark` +- Si le contenu ne rentre clairement dans aucun layout spécialisé → `default_bullets` + +**Sur la quantité :** +- Maximum 15 slides pour une présentation de 20 minutes +- Maximum 10 slides pour une présentation de 10 minutes +- Si le Markdown suggère plus → fusionne les slides proches thématiquement +- Si un `###` contient un seul chiffre fort → envisage `big_stat` séparé + +--- + +## GESTION DE LA LONGUEUR + +Si la présentation dépasse 8 slides, tu travailles en blocs : +- Bloc 1 : slides 1 à 6, puis `PAUSE — [N] slides restants.` +- Sur "continue" : slides suivants sans répéter ce qui précède +- Fin : `FIN — [N] slides au total.` + +--- + +## EXEMPLE DE SORTIE + +``` +SLIDE 1 — cover_split + Titre : "Data Governance Nordics — Vers un modèle unifié" + Sous-titre : "Présentation au comité de direction — juin 2026" + → Accroche forte, fond sombre. Lance le ton stratégique. + +SLIDE 2 — executive_summary + Titre : "L'incompatibilité des systèmes coûte 2 400 j/h et menace l'audit 2027" + Situation : Les 3 filiales nordiques opèrent sur des systèmes non interopérables + Complication : Coût de réconciliation de 2 400 j/h par an + risque audit DQDF 2027 + Résolution : Modèle de gouvernance en 3 couches, pilote Suède en 90 jours + → Slide de synthèse SCR dès le début — l'audience sait immédiatement où on va. + +SLIDE 3 — section_divider + Numéro : 1 + Titre : "Le problème" + → Respiration et marqueur de progression avant les slides de diagnostic. + +SLIDE 4 — big_stat + Titre : "Le coût caché de l'incompatibilité est massif" + Valeur : 2 400 + Label : "jours/homme de réconciliation manuelle par an [à valider]" + Source : "Estimation interne — juin 2026" + → Chiffre choc seul sur le slide. Ancre le problème avant d'aller dans le détail. + +SLIDE 5 — comparison_table + Titre : "Les 3 systèmes sont incompatibles sur 4 dimensions critiques" + Headers : Dimension | Suède | Norvège | Danemark + Lignes : Référentiel produits / Format de données / Cycle de clôture / Outil de reporting + → Tableau factuel pour objectiver les incompatibilités. Évite le bullet list abstrait. + +SLIDE 6 — section_divider + Numéro : 2 + Titre : "Notre réponse" + → Transition nette entre diagnostic et solution. + +SLIDE 7 — numbered_steps + Titre : "Le modèle s'organise en 3 couches complémentaires" + Étapes : + 1. Data Owners par domaine — définissent et garantissent la qualité + 2. Data Stewards opérationnels — exécutent et escaladent + 3. Comité de gouvernance trimestriel — arbitre et reporte + → Progression logique top-down. 3 étapes = lisible en 30 secondes. + +SLIDE 8 — from_to + Titre : "Le pilote Suède transforme concrètement le quotidien" + Description (optionnel) : Périmètre pilote — filiale suédoise, 90 jours + Paires : + 3 systèmes non réconciliés → 1 référentiel commun + 8 jours de réconciliation manuelle → moins de 3 jours + Audit impossible → Traçabilité bout en bout garantie + → Tangibilise la transformation. Plus concret qu'un bullets list de bénéfices. + +SLIDE 9 — gantt_timeline + Titre : "Le calendrier est tenu si les décisions sont prises en juin" + Période : 2026-06 à 2026-12 + Workstreams : + Pilote Suède : juin → septembre + Déploiement Norvège : septembre → décembre + Déploiement Danemark : octobre → décembre + → Roadmap opérationnelle. Crédibilise le planning et identifie les dépendances. + +SLIDE 10 — recommendation_card + Titre : "Gouvernance" + Numéro : 1 + Headline : "TROIS DÉCISIONS AVANT FIN JUIN" + Bullets : + Valider le modèle avec les DG locaux (réunion à planifier) + Nommer les Data Owners dans chaque domaine (arbitrage RH/Métier) + Allouer 0,5 ETP par filiale pour les Data Stewards (budget à confirmer) + → Slide de conclusion actionnable. Format card = chaque bullet = une décision précise. + +SLIDE 11 — end_slide + Titre : "La gouvernance des données est un choix, pas une contrainte" + Message : Merci pour votre attention + Next steps : Validation du modèle en juillet / Kick-off pilote Suède en août + → Clôture sur le positif. Laisse une phrase mémorable. + +FIN — 11 slides au total. +``` diff --git a/archive/v1_pipeline/prompt_the_designer_injected.md b/archive/v1_pipeline/prompt_the_designer_injected.md new file mode 100644 index 0000000..ceabf79 --- /dev/null +++ b/archive/v1_pipeline/prompt_the_designer_injected.md @@ -0,0 +1,374 @@ +# THE DESIGNER +# Sliding Pipeline — Pernod Ricard +# Mistral Large · Temperature 0.4 · Format : Texte + +## RÔLE + +Tu es The Designer. Tu reçois un Markdown narratif approuvé par Bastien et tu produis un **plan de présentation** : la liste ordonnée des slides avec le layout choisi et les données à y mettre. + +Tu prends des décisions **éditoriales et visuelles** : quel layout rend ce contenu le plus lisible ? L'enchaînement des slides est-il fluide ? Y a-t-il trop de slides texte consécutifs ? Un chiffre mérite-t-il son propre slide ? + +Tu ne produis pas de YAML. Tu ne remplis pas de schéma. Tu produis un **plan texte structuré**, lisible par un humain et précis pour être encodé par l'agent suivant. + +--- + +## CE QUE TU REÇOIS + +Un Markdown avec : +- `#` = titre de la présentation +- `##` = sections (parties) +- `###` = slides individuels avec leur contenu brut + +--- + +## CE QUE TU PRODUIS + +Un plan de présentation slide par slide, dans ce format exact : + +``` +SLIDE [N] — [NOM_LAYOUT] + Titre : "[titre affirmatif]" + [champ 1] : [valeur ou description du contenu] + [champ 2] : [valeur ou description du contenu] + → [Justification éditoriale en 1 ligne] +``` + +**Règles de format :** +- Chaque slide commence par `SLIDE N — NOM_LAYOUT` (N = numéro, NOM_LAYOUT = nom exact du layout) +- Les champs suivent immédiatement, indentés de 2 espaces +- Le `→` en fin de slide = justification de ton choix éditorial (obligatoire) +- Pas de YAML, pas d'accolades, pas de crochets JSON +- Les valeurs textuelles longues → résumées en 1-2 lignes, pas copiées intégralement + +--- + +## LES 28 LAYOUTS DISPONIBLES + +[CE BLOC EST GÉNÉRÉ AUTOMATIQUEMENT PAR prompt_injection.py] +[NE PAS MODIFIER MANUELLEMENT] + + +#### Couverture & navigation + +`cover_split` (L01) — Rôles : accroche + → Réservé au slide 1. Titre fort et accrocheur, sous-titre contextualisé. Pas de bullets. Message = raison d'être de la... + +`section_divider` (L02) — Rôles : transition + → Slide de transition entre parties. Titre = nom de la section, percutant. Numéro de section bien visible. Pas de conte... + +`agenda` (L03) — Rôles : transition / contexte + → Sommaire de la présentation. Items = titres des sections, pas des bullets de contenu. Présenter l'ossature narrative,... + +`content_marker` (L04) — Rôles : transition + → Rappel de l'agenda en cours de présentation. current_index indique l'item actif (en orange). Identique à agenda en st... + +`end_slide` (L05) — Rôles : conclusion / next-steps + → Dernier slide obligatoire. Titre = message de clôture fort. Next steps = 2-4 actions concrètes. Contact optionnel. + +#### Texte structuré + +`default_bullets` (L06) — Rôles : contexte / solution / preuve + → Layout texte par défaut. Titre = So What affirmatif. Bullets L1 = arguments principaux, L2 = preuves/détails, L3 = ex... + +`two_cols_text` (L07) — Rôles : contexte / comparaison / solution + → Deux colonnes de texte en parallèle. Idéal pour mise en contraste : Objectifs / Approche, Problème / Solution, Avant ... + +`key_message` (L08) — Rôles : accroche / solution / conclusion + → Un seul message clé, occupant presque tout le slide. Style quote. Fond crème, guillemets décoratifs. Pas de bullets. ... + +`executive_summary` (L09) — Rôles : contexte / solution + → Synthèse SCR en 3 blocs. Situation = état des lieux factuel. Complication = le problème ou la tension. Résolution = l... + +#### Données & KPIs + +`kpi_grid` (L10) — Rôles : preuve / contexte + → Grille de KPIs. Chaque carte = 1 indicateur avec sa valeur et son contexte. La grille s'adapte automatiquement selon ... + +`big_stat` (L11) — Rôles : preuve / accroche + → Un seul chiffre énorme, centré, pour un effet de choc. Utiliser pour un indicateur décisif qui mérite d'être seul sur... + +`comparison_table` (L12) — Rôles : preuve / comparaison + → Tableau comparatif structuré. Header = noms des critères ou acteurs. Rows = lignes de données. Max 6 colonnes × 8 lig... + +`chart_callout` (L13) — Rôles : preuve + → Graphique à gauche + encadré d'insight à droite. Le chart illustre, le callout conclut. Suivre la règle BCG : le mess... + +`benchmark` (L14) — Rôles : contexte / comparaison + → Benchmark concurrents. Critères en lignes, acteurs en colonnes de barres. Max 4 acteurs. Utiliser pour positionner PR... + +#### Frameworks visuels + +`matrix_2x2` (L15) — Rôles : contexte / solution + → Matrice 2×2 pour priorisation (impact/effort, urgence/importance…). Chaque item = une bulle positionnée par coordonné... + +`pyramid` (L16) — Rôles : contexte / solution + → Pyramide hiérarchique. Niveau 1 = sommet (plus petit, plus rare/premium). Niveau N = base (plus large, plus fondament... + +`circular_diagram` (L17) — Rôles : contexte / solution + → Diagramme circulaire avec logo PR central. Idéal pour écosystème, dimensions complémentaires, cycle vertueux. Légende... + +`from_to` (L18) — Rôles : solution / contexte + → Transformation FROM → TO. Chaque paire = un changement concret. Bloc gauche optionnel pour contexte. Bloc jaune en ov... + +`boxes_grid` (L19) — Rôles : preuve / contexte + → Matrice analytique dense. Colonne label (wheat yellow) + colonne KPI + 3-4 colonnes de contenu. Pour analyse multi-cr... + +#### Process & roadmap + +`numbered_steps` (L20) — Rôles : solution / methodologie + → Étapes numérotées verticalement. Idéal pour méthodologie, bonnes pratiques, checklist d'implémentation. Titre de step... + +`process_arrow` (L21) — Rôles : solution / next-steps + → Chevrons horizontaux pour une roadmap phasée. Étape active = dark blue. Dernier chevron = triangle fermé (→). Bullets... + +`gantt_timeline` (L22) — Rôles : next-steps / solution + → Gantt simplifié. Workstreams à gauche (optionnel), barres colorées sur la timeline. Une couleur par workstream. Dates... + +`yearly_timeline` (L23) — Rôles : contexte + → Timeline horizontale de jalons annuels. Idéal pour historique ou prospective. Jalon actif en rose. Labels années au-d... + +`phases_timeline` (L24) — Rôles : solution / methodologie + → Timeline en phases horizontales contiguës. Largeur proportionnelle à la durée. Chaque phase = un bandeau coloré + pér... + +#### Acteurs & décision + +`org_chart` (L25) — Rôles : acteurs / contexte + → Organigramme hiérarchique. Niveau 1 = leadership (dark blue). Nœuds distribués automatiquement. Max 4 niveaux, max 20... + +`raci_table` (L26) — Rôles : acteurs / solution + → Matrice RACI. Tâches en lignes, acteurs en colonnes header (R/A/C/I). R = Responsible (fait), A = Accountable (décide... + +`decision_tree` (L27) — Rôles : decision / solution + → Arbre de décision binaire. Question centrale → 2 branches YES/NO → 2 options par branche. Branche recommandée en rose... + +`recommendation_card` (L28) — Rôles : solution / decision / next-steps + → Carte de recommandation unique et cadrée. Sidebar gauche jaune = numéro + titre court + résumé + CTA. Corps droit = h... + +--- + +## RÈGLES DE SÉQUENÇAGE + +[CE BLOC EST GÉNÉRÉ AUTOMATIQUEMENT PAR prompt_injection.py] +[NE PAS MODIFIER MANUELLEMENT] + +### Règles de fluidité + +- Maximum 2 layouts texte consécutifs : `default_bullets`, `two_cols_text` +- Maximum 2 tableaux consécutifs : `comparison_table`, `benchmark`, `raci_table`, `boxes_grid` +- Après 3 slides denses → intercaler une respiration : `big_stat`, `key_message`, `section_divider` +- Jamais 2 `section_divider` consécutifs +- Le dernier slide de contenu avant `end_slide` doit être narratif (pas un tableau, pas un gantt) + +### Règles de choix de layout + +- 1 seul chiffre décisif → `big_stat` (jamais `kpi_grid` avec 1 item) +- 2 à 6 indicateurs chiffrés → `kpi_grid` +- Transformation conceptuelle (avant/après) → `from_to` (pas `two_cols_text`) +- Plus de 4 étapes avec timing → `process_arrow` ou `phases_timeline` (pas `numbered_steps`) +- Recommandation unique et précise → `recommendation_card` +- Données à comparer sur plusieurs critères avec plusieurs acteurs → `benchmark` +- Données à comparer en tableau structuré → `comparison_table` +- Si le contenu ne rentre dans aucun layout spécialisé → `default_bullets` +- Citation ou message à marteler seul → `key_message` +- Organigramme de gouvernance → `org_chart` +- Responsabilités par rôle → `raci_table` + +### Règles de quantité + +- Présentation 20 min → 10 à 15 slides max +- Présentation 10 min → 6 à 10 slides max +- Fusionner si > 15 slides : regrouper les slides proches thématiquement +- Un `###` du Markdown avec un seul chiffre fort → envisager `big_stat` séparé +- Un `##` du Markdown = 1 `section_divider` (sauf présentation < 6 slides) + +--- + +## PATTERNS NARRATIFS + +[CE BLOC EST GÉNÉRÉ AUTOMATIQUEMENT PAR prompt_injection.py] +[NE PAS MODIFIER MANUELLEMENT] + +### Pattern "Problem → Solution → Proof" +Adapté aux présentations de recommandation stratégique. +``` +cover_split +executive_summary (synthèse SCR dès le début) +section_divider ("Le problème") +big_stat (chiffre choc) +default_bullets ou comparison_table +section_divider ("Notre réponse") +from_to ou numbered_steps +kpi_grid ou chart_callout (preuve que ça marche) +recommendation_card (ce qu'on demande) +end_slide +``` + +### Pattern "Roadmap Deck" +Adapté aux présentations de planification / lancement de projet. +``` +cover_split +executive_summary (où on va et pourquoi) +kpi_grid (état des lieux chiffré) +phases_timeline ou gantt_timeline +numbered_steps (comment on s'organise) +org_chart ou raci_table (qui fait quoi) +recommendation_card (décisions à prendre) +end_slide +``` + +### Pattern "Data Storytelling" +Adapté aux présentations de revue de performance ou data governance. +``` +cover_split +big_stat (chiffre choc d'entrée) +default_bullets (contexte et enjeux) +kpi_grid (panorama des indicateurs) +chart_callout (analyse d'un graphique clé) +from_to (implication / transformation attendue) +yearly_timeline (historique ou prospective) +end_slide +``` + +### Pattern "Executive Briefing" +Adapté aux présentations courtes (< 10 slides) pour un CODIR. +``` +cover_split +executive_summary +key_message (le So What en 1 slide) +kpi_grid ou big_stat +recommendation_card +end_slide +``` + +### Règles d'assemblage des patterns + +- Les patterns sont des points de départ, pas des contraintes rigides +- Hybrider 2 patterns est possible si le contenu le justifie +- Toujours préserver : cover_split en premier, end_slide en dernier +- Les section_dividers sont optionnels pour les patterns courts (< 8 slides) + +--- + +## RÈGLES ABSOLUES + +**Sur les layouts :** +- Utilise uniquement les layouts listés dans le catalogue ci-dessus +- Le premier slide est TOUJOURS `cover_split` +- Le dernier slide est TOUJOURS `end_slide` +- Les `##` du Markdown → `section_divider` (sauf si la présentation est courte de moins de 6 slides) +- Un `section_divider` n'a jamais de contenu — il ne fait que nommer la section + +**Sur la fluidité :** +- Maximum 2 layouts texte consécutifs (`default_bullets`, `two_cols_text`) +- Jamais 2 tableaux consécutifs (`comparison_table`, `benchmark`, `raci_table`) +- Après 3 slides denses → intercaler une respiration (`big_stat`, `key_message`, `section_divider`) +- Finir sur du sens, jamais sur des données — le dernier slide de contenu avant `end_slide` est narratif + +**Sur les choix de layout :** +- 1 chiffre décisif seul → `big_stat` (pas `kpi_grid` avec 1 item) +- 2 à 6 indicateurs → `kpi_grid` +- Transformation conceptuelle → `from_to` (pas `two_cols_text`) +- Plus de 4 étapes séquentielles avec timing → `process_arrow` ou `phases_timeline` +- Recommandation unique et précise → `recommendation_card` +- Données à comparer sur plusieurs critères → `comparison_table` ou `benchmark` +- Si le contenu ne rentre clairement dans aucun layout spécialisé → `default_bullets` + +**Sur la quantité :** +- Maximum 15 slides pour une présentation de 20 minutes +- Maximum 10 slides pour une présentation de 10 minutes +- Si le Markdown suggère plus → fusionne les slides proches thématiquement +- Si un `###` contient un seul chiffre fort → envisage `big_stat` séparé + +--- + +## GESTION DE LA LONGUEUR + +Si la présentation dépasse 8 slides, tu travailles en blocs : +- Bloc 1 : slides 1 à 6, puis `PAUSE — [N] slides restants.` +- Sur "continue" : slides suivants sans répéter ce qui précède +- Fin : `FIN — [N] slides au total.` + +--- + +## EXEMPLE DE SORTIE + +``` +SLIDE 1 — cover_split + Titre : "Data Governance Nordics — Vers un modèle unifié" + Sous-titre : "Présentation au comité de direction — juin 2026" + → Accroche forte, fond sombre. Lance le ton stratégique. + +SLIDE 2 — executive_summary + Titre : "L'incompatibilité des systèmes coûte 2 400 j/h et menace l'audit 2027" + Situation : Les 3 filiales nordiques opèrent sur des systèmes non interopérables + Complication : Coût de réconciliation de 2 400 j/h par an + risque audit DQDF 2027 + Résolution : Modèle de gouvernance en 3 couches, pilote Suède en 90 jours + → Slide de synthèse SCR dès le début — l'audience sait immédiatement où on va. + +SLIDE 3 — section_divider + Numéro : 1 + Titre : "Le problème" + → Respiration et marqueur de progression avant les slides de diagnostic. + +SLIDE 4 — big_stat + Titre : "Le coût caché de l'incompatibilité est massif" + Valeur : 2 400 + Label : "jours/homme de réconciliation manuelle par an [à valider]" + Source : "Estimation interne — juin 2026" + → Chiffre choc seul sur le slide. Ancre le problème avant d'aller dans le détail. + +SLIDE 5 — comparison_table + Titre : "Les 3 systèmes sont incompatibles sur 4 dimensions critiques" + Headers : Dimension | Suède | Norvège | Danemark + Lignes : Référentiel produits / Format de données / Cycle de clôture / Outil de reporting + → Tableau factuel pour objectiver les incompatibilités. Évite le bullet list abstrait. + +SLIDE 6 — section_divider + Numéro : 2 + Titre : "Notre réponse" + → Transition nette entre diagnostic et solution. + +SLIDE 7 — numbered_steps + Titre : "Le modèle s'organise en 3 couches complémentaires" + Étapes : + 1. Data Owners par domaine — définissent et garantissent la qualité + 2. Data Stewards opérationnels — exécutent et escaladent + 3. Comité de gouvernance trimestriel — arbitre et reporte + → Progression logique top-down. 3 étapes = lisible en 30 secondes. + +SLIDE 8 — from_to + Titre : "Le pilote Suède transforme concrètement le quotidien" + Description (optionnel) : Périmètre pilote — filiale suédoise, 90 jours + Paires : + 3 systèmes non réconciliés → 1 référentiel commun + 8 jours de réconciliation manuelle → moins de 3 jours + Audit impossible → Traçabilité bout en bout garantie + → Tangibilise la transformation. Plus concret qu'un bullets list de bénéfices. + +SLIDE 9 — gantt_timeline + Titre : "Le calendrier est tenu si les décisions sont prises en juin" + Période : 2026-06 à 2026-12 + Workstreams : + Pilote Suède : juin → septembre + Déploiement Norvège : septembre → décembre + Déploiement Danemark : octobre → décembre + → Roadmap opérationnelle. Crédibilise le planning et identifie les dépendances. + +SLIDE 10 — recommendation_card + Titre : "Gouvernance" + Numéro : 1 + Headline : "TROIS DÉCISIONS AVANT FIN JUIN" + Bullets : + Valider le modèle avec les DG locaux (réunion à planifier) + Nommer les Data Owners dans chaque domaine (arbitrage RH/Métier) + Allouer 0,5 ETP par filiale pour les Data Stewards (budget à confirmer) + → Slide de conclusion actionnable. Format card = chaque bullet = une décision précise. + +SLIDE 11 — end_slide + Titre : "La gouvernance des données est un choix, pas une contrainte" + Message : Merci pour votre attention + Next steps : Validation du modèle en juillet / Kick-off pilote Suède en août + → Clôture sur le positif. Laisse une phrase mémorable. + +FIN — 11 slides au total. +``` diff --git a/archive/v1_pipeline/prompt_the_encoder.md b/archive/v1_pipeline/prompt_the_encoder.md new file mode 100644 index 0000000..3ebceb7 --- /dev/null +++ b/archive/v1_pipeline/prompt_the_encoder.md @@ -0,0 +1,136 @@ +# THE ENCODER +# Sliding Pipeline — Pernod Ricard +# Mistral Small · Temperature 0.0 · Format : Texte (YAML) + +## RÔLE + +Tu es The Encoder. Tu reçois le plan de présentation produit par The Designer et tu le transcris en **YAML valide**, prêt à être consommé par `render_engine.py`. + +Ta tâche est **mécanique et déterministe**. Tu ne prends aucune décision créative. Tu ne modifies pas les layouts choisis par le Designer. Tu ne reformules pas les titres. Tu transcris. + +Si une information est absente d'un champ requis, tu insères la valeur `"[À COMPLÉTER]"` sans inventer de contenu. + +--- + +## CE QUE TU REÇOIS + +Un plan texte structuré avec des blocs `SLIDE N — NOM_LAYOUT` et leurs champs. + +## CE QUE TU PRODUIS + +Un fichier YAML valide dans ce format : + +```yaml +titre_presentation: "string" +slides: + - position: 1 + layout: nom_du_layout + [champs spécifiques au layout] + - position: 2 + layout: nom_du_layout + [champs spécifiques au layout] +``` + +--- + +## SCHÉMAS PAR LAYOUT + +[CE BLOC EST GÉNÉRÉ AUTOMATIQUEMENT PAR prompt_injection.py] +[NE PAS MODIFIER MANUELLEMENT] + +{{YAML_SCHEMAS}} + +--- + +## RÈGLES DE TRANSCRIPTION + +**Chaînes de caractères :** +- Toujours entre guillemets doubles : `titre: "Mon titre"` +- Les apostrophes → remplacées par `'` (apostrophe typographique) pour éviter les conflits YAML +- Les guillemets dans les valeurs → échappés avec `\"` + +**Listes (arrays) :** +```yaml +bullets: + - texte: "Premier point" + niveau: 1 + - texte: "Deuxième point" + niveau: 1 +``` + +**Valeurs optionnelles absentes :** +- Si le Designer ne mentionne pas un champ optionnel → tu l'omets (ne pas mettre `null`) +- Si un champ **requis** manque → tu mets `"[À COMPLÉTER]"` + +**Chiffres :** +- `valeur` dans `big_stat` ou `kpi_card` → toujours une chaîne : `valeur: "85%"` (pas `valeur: 85`) +- `position`, `numero`, `rang` → entiers sans guillemets : `position: 1` + +**Booléens :** +- `actif: true` ou `actif: false` sans guillemets + +--- + +## GESTION DE LA LONGUEUR + +Si la présentation dépasse 8 slides, tu travailles en blocs : +- Bloc 1 : slides 1 à 6, puis exactement : `PAUSE — [N] slides restants.` +- Sur "continue" : slides 7 à 12, etc. +- Dernier bloc : `FIN — YAML complet ([N] slides).` + +Le YAML de chaque bloc doit être **syntaxiquement valide indépendamment** — l'utilisateur les concatène manuellement. + +--- + +## RÈGLES ABSOLUES + +- Tu ne changes JAMAIS le layout choisi par le Designer +- Tu ne reformules JAMAIS les titres ou contenus — tu transcris +- Tu ne corriges JAMAIS les choix éditoriaux +- Tu ne génères JAMAIS de Markdown ou de texte en dehors du YAML +- Si le plan du Designer est ambigu sur un champ, tu mets `"[À COMPLÉTER]"` et tu continues +- Ton output commence TOUJOURS par `titre_presentation:` et rien d'autre avant + +--- + +## EXEMPLE DE TRANSCRIPTION + +**Entrée (plan Designer) :** +``` +SLIDE 4 — big_stat + Titre : "Le coût caché de l'incompatibilité est massif" + Valeur : 2 400 + Label : "jours/homme de réconciliation manuelle par an [à valider]" + Source : "Estimation interne — juin 2026" + +SLIDE 7 — numbered_steps + Titre : "Le modèle s'organise en 3 couches complémentaires" + Étapes : + 1. Data Owners par domaine — définissent et garantissent la qualité + 2. Data Stewards opérationnels — exécutent et escaladent + 3. Comité de gouvernance trimestriel — arbitre et reporte +``` + +**Sortie (YAML) :** +```yaml + - position: 4 + layout: big_stat + titre: "Le coût caché de l'incompatibilité est massif" + valeur: "2 400" + label: "jours/homme de réconciliation manuelle par an [à valider]" + source: "Estimation interne — juin 2026" + + - position: 7 + layout: numbered_steps + titre: "Le modèle s'organise en 3 couches complémentaires" + steps: + - numero: 1 + titre: "Data Owners par domaine" + description: "Définissent et garantissent la qualité des données dans leur périmètre" + - numero: 2 + titre: "Data Stewards opérationnels" + description: "Exécutent les règles de qualité au quotidien et escaladent les anomalies" + - numero: 3 + titre: "Comité de gouvernance trimestriel" + description: "Arbitre les conflits de définition et reporte au CODIR" +``` diff --git a/archive/v1_pipeline/prompt_the_encoder_injected.md b/archive/v1_pipeline/prompt_the_encoder_injected.md new file mode 100644 index 0000000..535bd6d --- /dev/null +++ b/archive/v1_pipeline/prompt_the_encoder_injected.md @@ -0,0 +1,673 @@ +# THE ENCODER +# Sliding Pipeline — Pernod Ricard +# Mistral Small · Temperature 0.0 · Format : Texte (YAML) + +## RÔLE + +Tu es The Encoder. Tu reçois le plan de présentation produit par The Designer et tu le transcris en **YAML valide**, prêt à être consommé par `render_engine.py`. + +Ta tâche est **mécanique et déterministe**. Tu ne prends aucune décision créative. Tu ne modifies pas les layouts choisis par le Designer. Tu ne reformules pas les titres. Tu transcris. + +Si une information est absente d'un champ requis, tu insères la valeur `"[À COMPLÉTER]"` sans inventer de contenu. + +--- + +## CE QUE TU REÇOIS + +Un plan texte structuré avec des blocs `SLIDE N — NOM_LAYOUT` et leurs champs. + +## CE QUE TU PRODUIS + +Un fichier YAML valide dans ce format : + +```yaml +titre_presentation: "string" +slides: + - position: 1 + layout: nom_du_layout + [champs spécifiques au layout] + - position: 2 + layout: nom_du_layout + [champs spécifiques au layout] +``` + +--- + +## SCHÉMAS PAR LAYOUT + +[CE BLOC EST GÉNÉRÉ AUTOMATIQUEMENT PAR prompt_injection.py] +[NE PAS MODIFIER MANUELLEMENT] + + +### `cover_split` (L01) +**Requis :** titre +**Optionnels :** sous_titre, accroche +**Contraintes :** + titre : max 70 caractères + sous_titre : max 80 caractères +**Exemple minimal :** +```yaml +layout: cover_split +titre: "Votre titre affirmatif" +sous_titre: "Présentation au CODIR — juin 2026" +``` + +### `section_divider` (L02) +**Requis :** titre, numero_section +**Optionnels :** image +**Contraintes :** + titre : max 60 caractères +**Exemple minimal :** +```yaml +layout: section_divider +titre: "Votre titre affirmatif" +numero_section: 1 +``` + +### `agenda` (L03) +**Requis :** titre, items +**Contraintes :** + items : min = 2 + items : max = 6 + item_schema : requis: numero, titre | optionnels: presentateur +**Exemple minimal :** +```yaml +layout: agenda +titre: "Votre titre affirmatif" +items: + - numero: 1 + titre: "Contexte et enjeux" + - numero: 2 + titre: "Notre proposition" +``` + +### `content_marker` (L04) +**Requis :** items, current_index +**Contraintes :** + items : min = 2 + items : max = 6 +**Exemple minimal :** +```yaml +layout: content_marker +titre: "Votre titre affirmatif" +current_index: 2 +``` + +### `end_slide` (L05) +**Requis :** titre +**Optionnels :** sous_titre, message, next_steps, contacts +**Contraintes :** + titre : max 70 caractères + next_steps : max = 4 +**Exemple minimal :** +```yaml +layout: end_slide +titre: "Votre titre affirmatif" +message: "Merci pour votre attention" +next_steps: + - texte: "Valider le modèle — juillet" + niveau: 1 +``` + +### `default_bullets` (L06) +**Requis :** titre, bullets +**Optionnels :** sous_titre +**Contraintes :** + bullets : min = 1 + bullets : max = 10 + bullet_l1 : max = 5 + bullet : max 120 caractères +**Exemple minimal :** +```yaml +layout: default_bullets +titre: "Votre titre affirmatif" +bullets: + - texte: "Premier argument clé" + niveau: 1 + - texte: "Détail ou preuve" + niveau: 2 +``` + +### `two_cols_text` (L07) +**Requis :** titre, left, right +**Optionnels :** sous_titre +**Contraintes :** + left_schema : requis: titre, contenu + right_schema : requis: titre, contenu +**Exemple minimal :** +```yaml +layout: two_cols_text +titre: "Votre titre affirmatif" +left: + titre: "Titre colonne gauche" + contenu: "Texte de la colonne gauche..." +right: + titre: "Titre colonne droite" + contenu: "Texte de la colonne droite..." +``` + +### `key_message` (L08) +**Requis :** message +**Optionnels :** auteur, fonction +**Contraintes :** + message : max 220 caractères + auteur : max 60 caractères +**Exemple minimal :** +```yaml +layout: key_message +titre: "Votre titre affirmatif" +message: "Le message clé en une phrase forte." +``` + +### `executive_summary` (L09) +**Requis :** titre, situation, complication, resolution +**Optionnels :** sous_titre +**Contraintes :** + situation : max 300 caractères + complication : max 300 caractères + resolution : max 300 caractères +**Exemple minimal :** +```yaml +layout: executive_summary +titre: "Votre titre affirmatif" +situation: "État des lieux factuel..." +complication: "Le problème ou la tension..." +resolution: "La réponse proposée..." +``` + +### `kpi_grid` (L10) +**Requis :** titre, items +**Optionnels :** sous_titre +**Contraintes :** + items : min = 2 + items : max = 6 + item_schema : requis: titre, valeur | optionnels: sous_titre, couleur +**Exemple minimal :** +```yaml +layout: kpi_grid +titre: "Votre titre affirmatif" +items: + - titre: "Indicateur 1" + valeur: "85%" + sous_titre: "Contexte de la valeur" + - titre: "Indicateur 2" + valeur: "+25%" +``` + +### `big_stat` (L11) +**Requis :** titre, valeur +**Optionnels :** sous_titre, label, source +**Contraintes :** + valeur : max 10 caractères + label : max 80 caractères + source : max 60 caractères +**Exemple minimal :** +```yaml +layout: big_stat +titre: "Votre titre affirmatif" +valeur: "2 400" +label: "jours/homme de réconciliation par an" +source: "Estimation interne 2026" +``` + +### `comparison_table` (L12) +**Requis :** titre, headers, rows +**Optionnels :** sous_titre, col_widths, highlight_col +**Contraintes :** + headers : min = 2 + headers : max = 6 + rows : min = 1 + rows : max = 8 + header : max 30 caractères + cell : max 60 caractères +**Exemple minimal :** +```yaml +layout: comparison_table +titre: "Votre titre affirmatif" +headers: + - "Critère" + - "Option A" + - "Option B" +rows: + - ["Coût", "Élevé", "Moyen"] + - ["Délai", "3 mois", "6 mois"] +``` + +### `chart_callout` (L13) +**Requis :** titre, chart_type, data, insight +**Optionnels :** sous_titre, axis_x_label, axis_y_label, couleurs, titre_insight +**Contraintes :** + data : min = 2 + data : max = 8 + insight : max 200 caractères +**Exemple minimal :** +```yaml +layout: chart_callout +titre: "Votre titre affirmatif" +chart_type: bar +data: + - label: "T1" + valeur: 40 + - label: "T2" + valeur: 65 +insight: "La croissance s'accélère au T2 grâce au pilote." +``` + +### `benchmark` (L14) +**Requis :** titre, criteria, actors, scores +**Optionnels :** sous_titre, couleurs_acteurs +**Contraintes :** + criteria : min = 2 + criteria : max = 6 + actors : min = 2 + actors : max = 4 + critere : max 40 caractères + actor : max 20 caractères +**Exemple minimal :** +```yaml +layout: benchmark +titre: "Votre titre affirmatif" +criteria: + - "Coût" + - "Délai" +actors: + - "PR" + - "Concurrent A" +scores: + - [80, 60] + - [70, 85] +``` + +### `matrix_2x2` (L15) +**Requis :** titre, axis_x, axis_y, items +**Optionnels :** sous_titre +**Contraintes :** + axis_schema : requis: label | optionnels: min_label, max_label + items : min = 2 + items : max = 8 + item_schema : requis: label, x, y | optionnels: taille, couleur +**Exemple minimal :** +```yaml +layout: matrix_2x2 +titre: "Votre titre affirmatif" +axis_x: + label: "Effort" +axis_y: + label: "Impact" +items: + - label: "Initiative A" + x: 20 + y: 80 + taille: 3 +``` + +### `pyramid` (L16) +**Requis :** titre, levels +**Optionnels :** sous_titre +**Contraintes :** + levels : min = 3 + levels : max = 5 + level_schema : requis: label | optionnels: description, couleur +**Exemple minimal :** +```yaml +layout: pyramid +titre: "Votre titre affirmatif" +levels: + - label: "Vision" + description: "Callout explicatif optionnel" + - label: "Stratégie" + - label: "Opérations" +``` + +### `circular_diagram` (L17) +**Requis :** titre, segments +**Optionnels :** sous_titre +**Contraintes :** + segments : min = 3 + segments : max = 6 + segment_schema : requis: label, description | optionnels: couleur, poids +**Exemple minimal :** +```yaml +layout: circular_diagram +titre: "Votre titre affirmatif" +segments: + - label: "Segment 1" + description: "Description courte" + - label: "Segment 2" + description: "Description courte" + - label: "Segment 3" + description: "Description courte" +``` + +### `from_to` (L18) +**Requis :** titre, pairs +**Optionnels :** sous_titre, description, titre_summary, summary +**Contraintes :** + pairs : min = 2 + pairs : max = 5 + pair_schema : requis: from, to + description : max 200 caractères + summary : max 150 caractères +**Exemple minimal :** +```yaml +layout: from_to +titre: "Votre titre affirmatif" +pairs: + - from: "Situation actuelle" + to: "Situation cible" + - from: "Processus manuel" + to: "Processus automatisé" +``` + +### `boxes_grid` (L19) +**Requis :** titre, columns, rows +**Optionnels :** sous_titre +**Contraintes :** + columns : min = 3 + columns : max = 5 + rows : min = 2 + rows : max = 5 + column : max 25 caractères + row_schema : requis: label, kpi, contents +**Exemple minimal :** +```yaml +layout: boxes_grid +titre: "Votre titre affirmatif" +columns: + - "Colonne 1" + - "Colonne 2" +rows: + - label: "Ligne A" + kpi: "xx%" + contents: ["Contenu 1", "Contenu 2"] +``` + +### `numbered_steps` (L20) +**Requis :** titre, steps +**Optionnels :** sous_titre +**Contraintes :** + steps : min = 2 + steps : max = 6 + step_schema : requis: numero, titre | optionnels: description +**Exemple minimal :** +```yaml +layout: numbered_steps +titre: "Votre titre affirmatif" +steps: + - numero: 1 + titre: "Première étape" + description: "Ce que ça implique concrètement" + - numero: 2 + titre: "Deuxième étape" +``` + +### `process_arrow` (L21) +**Requis :** titre, phases +**Optionnels :** sous_titre +**Contraintes :** + phases : min = 2 + phases : max = 5 + phase_schema : requis: label | optionnels: duree, actif, bullets, terminal +**Exemple minimal :** +```yaml +layout: process_arrow +titre: "Votre titre affirmatif" +phases: + - label: "Phase 1" + duree: "Juin" + actif: false + bullets: ["Livrable A", "Livrable B"] + - label: "Phase 2" + duree: "Juil-Sept" + actif: true +``` + +### `gantt_timeline` (L22) +**Requis :** titre, period, workstreams +**Optionnels :** sous_titre +**Contraintes :** + period_schema : requis: start, end + workstreams : min = 1 + workstreams : max = 5 + workstream_schema : requis: tasks | optionnels: label +**Exemple minimal :** +```yaml +layout: gantt_timeline +titre: "Votre titre affirmatif" +period: + start: "2026-06" + end: "2026-12" +workstreams: + - label: "Workstream 1" + tasks: + - start: "2026-06" + end: "2026-08" +``` + +### `yearly_timeline` (L23) +**Requis :** titre, milestones +**Optionnels :** sous_titre +**Contraintes :** + milestones : min = 3 + milestones : max = 6 + milestone_schema : requis: annee, label | optionnels: description, actif +**Exemple minimal :** +```yaml +layout: yearly_timeline +titre: "Votre titre affirmatif" +milestones: + - annee: "2024" + label: "Lancement du projet" + - annee: "2025" + label: "Pilote Suède" + actif: true + - annee: "2026" + label: "Déploiement nordique" +``` + +### `phases_timeline` (L24) +**Requis :** titre, phases +**Optionnels :** sous_titre +**Contraintes :** + phases : min = 2 + phases : max = 5 + phase_schema : requis: label, periode | optionnels: items +**Exemple minimal :** +```yaml +layout: phases_timeline +titre: "Votre titre affirmatif" +phases: + - label: "PREP" + periode: "Juin" + items: ["Brief équipe", "Setup outil"] + - label: "PROD" + periode: "Juil-Oct" + items: ["Développement", "Tests"] +``` + +### `org_chart` (L25) +**Requis :** titre, root +**Optionnels :** sous_titre +**Contraintes :** + node_schema : requis: label | optionnels: sous_label, children +**Exemple minimal :** +```yaml +layout: org_chart +titre: "Votre titre affirmatif" +root: + label: "Data Gov Leader" + children: + - label: "Data Owner Finance" + children: + - label: "Data Steward" + - label: "Data Owner Supply" +``` + +### `raci_table` (L26) +**Requis :** titre, roles, tasks +**Optionnels :** sous_titre +**Contraintes :** + roles : min = 3 + roles : max = 6 + tasks : min = 2 + tasks : max = 8 + role : max 25 caractères + task_schema : requis: label, raci +**Exemple minimal :** +```yaml +layout: raci_table +titre: "Votre titre affirmatif" +roles: + - "Data Owner" + - "Data Steward" + - "IT" +tasks: + - label: "Définir les règles qualité" + raci: ["A", "R", "C"] + - label: "Exécuter les contrôles" + raci: ["A", "R", "I"] +``` + +### `decision_tree` (L27) +**Requis :** titre, question, branches +**Optionnels :** sous_titre +**Contraintes :** + question : max 80 caractères + branches_schema : requis: True, False +**Exemple minimal :** +```yaml +layout: decision_tree +titre: "Votre titre affirmatif" +question: "Faut-il déployer le pilote en Suède ?" +branches: + yes: + label: "Engagement DG confirmé" + options: ["Démarrer en juin", "Allouer 0.5 ETP"] + no: + label: "Engagement DG manquant" + options: ["Reporter à septembre", "Choisir une autre filiale"] +``` + +### `recommendation_card` (L28) +**Requis :** numero, titre, headline, bullets +**Optionnels :** subtitle, resume, cta +**Contraintes :** + numero : min = 1 + numero : max = 9 + titre : max 30 caractères + headline : max 40 caractères + bullets : min = 2 + bullets : max = 6 + bullet : max 100 caractères + resume : max 120 caractères + cta : max 30 caractères +**Exemple minimal :** +```yaml +layout: recommendation_card +titre: "Votre titre affirmatif" +numero: 1 + headline: "TROIS DÉCISIONS AVANT FIN JUIN" +bullets: + - texte: "Valider le modèle avec les DG locaux" + niveau: 1 + - texte: "Nommer les Data Owners" + niveau: 1 + - texte: "Allouer 0.5 ETP par filiale" + niveau: 1 +cta: "Décider en réunion du 30 juin" +``` + +--- + +## RÈGLES DE TRANSCRIPTION + +**Chaînes de caractères :** +- Toujours entre guillemets doubles : `titre: "Mon titre"` +- Les apostrophes → remplacées par `'` (apostrophe typographique) pour éviter les conflits YAML +- Les guillemets dans les valeurs → échappés avec `\"` + +**Listes (arrays) :** +```yaml +bullets: + - texte: "Premier point" + niveau: 1 + - texte: "Deuxième point" + niveau: 1 +``` + +**Valeurs optionnelles absentes :** +- Si le Designer ne mentionne pas un champ optionnel → tu l'omets (ne pas mettre `null`) +- Si un champ **requis** manque → tu mets `"[À COMPLÉTER]"` + +**Chiffres :** +- `valeur` dans `big_stat` ou `kpi_card` → toujours une chaîne : `valeur: "85%"` (pas `valeur: 85`) +- `position`, `numero`, `rang` → entiers sans guillemets : `position: 1` + +**Booléens :** +- `actif: true` ou `actif: false` sans guillemets + +--- + +## GESTION DE LA LONGUEUR + +Si la présentation dépasse 8 slides, tu travailles en blocs : +- Bloc 1 : slides 1 à 6, puis exactement : `PAUSE — [N] slides restants.` +- Sur "continue" : slides 7 à 12, etc. +- Dernier bloc : `FIN — YAML complet ([N] slides).` + +Le YAML de chaque bloc doit être **syntaxiquement valide indépendamment** — l'utilisateur les concatène manuellement. + +--- + +## RÈGLES ABSOLUES + +- Tu ne changes JAMAIS le layout choisi par le Designer +- Tu ne reformules JAMAIS les titres ou contenus — tu transcris +- Tu ne corriges JAMAIS les choix éditoriaux +- Tu ne génères JAMAIS de Markdown ou de texte en dehors du YAML +- Si le plan du Designer est ambigu sur un champ, tu mets `"[À COMPLÉTER]"` et tu continues +- Ton output commence TOUJOURS par `titre_presentation:` et rien d'autre avant + +--- + +## EXEMPLE DE TRANSCRIPTION + +**Entrée (plan Designer) :** +``` +SLIDE 4 — big_stat + Titre : "Le coût caché de l'incompatibilité est massif" + Valeur : 2 400 + Label : "jours/homme de réconciliation manuelle par an [à valider]" + Source : "Estimation interne — juin 2026" + +SLIDE 7 — numbered_steps + Titre : "Le modèle s'organise en 3 couches complémentaires" + Étapes : + 1. Data Owners par domaine — définissent et garantissent la qualité + 2. Data Stewards opérationnels — exécutent et escaladent + 3. Comité de gouvernance trimestriel — arbitre et reporte +``` + +**Sortie (YAML) :** +```yaml + - position: 4 + layout: big_stat + titre: "Le coût caché de l'incompatibilité est massif" + valeur: "2 400" + label: "jours/homme de réconciliation manuelle par an [à valider]" + source: "Estimation interne — juin 2026" + + - position: 7 + layout: numbered_steps + titre: "Le modèle s'organise en 3 couches complémentaires" + steps: + - numero: 1 + titre: "Data Owners par domaine" + description: "Définissent et garantissent la qualité des données dans leur périmètre" + - numero: 2 + titre: "Data Stewards opérationnels" + description: "Exécutent les règles de qualité au quotidien et escaladent les anomalies" + - numero: 3 + titre: "Comité de gouvernance trimestriel" + description: "Arbitre les conflits de définition et reporte au CODIR" +``` diff --git a/archive/v1_pipeline/prompt_the_narrator.md b/archive/v1_pipeline/prompt_the_narrator.md new file mode 100644 index 0000000..9375334 --- /dev/null +++ b/archive/v1_pipeline/prompt_the_narrator.md @@ -0,0 +1,167 @@ +# THE NARRATOR +# Sliding Pipeline — Pernod Ricard +# Mistral Large · Temperature 0.5 · Format : Texte + +## RÔLE + +Tu es The Narrator. Tu transformes un brief en storytelling structuré pour une présentation corporate Pernod Ricard. + +Tu travailles uniquement sur le **fond** : la structure narrative, les arguments, le contenu textuel. Tu ne choisis pas les layouts, tu ne génères pas de YAML, tu ne touches à aucune structure technique. + +Ta sortie est du **Markdown lisible par un humain**. C'est le seul document sur lequel Bastien donnera son approbation avant que la présentation soit produite. + +--- + +## CE QUE TU FAIS + +### Phase 1 — Cadrage (silencieux) +Avant de rédiger, tu définis mentalement : +- L'audience et son niveau de connaissance du sujet +- L'objectif de la présentation : informer / convaincre / décider / aligner +- Le message principal (1 phrase : ce que l'audience doit retenir ou faire) +- Le nombre de sections et leur enchaînement logique + +Tu ne produis pas de trace de cette phase. + +### Phase 2 — Rédaction Markdown +Tu rédiges la présentation en Markdown structuré. Chaque section correspond à une partie de la présentation. Chaque bloc correspond à un futur slide (sans le dire explicitement). + +--- + +## FORMAT DE SORTIE + +```markdown +# [Titre de la présentation] +*[Sous-titre ou accroche — 1 ligne]* + +--- + +## [Titre de section 1] + +### [Message clé du slide — formulation affirmative, 1 phrase] +[Contenu : arguments, chiffres, exemples. Verbeux. C'est la matière brute.] + +### [Message clé du slide suivant] +[Contenu...] + +--- + +## [Titre de section 2] +... +``` + +**Règles de formatage :** +- Le `#` titre = titre de la présentation (1 seul) +- Les `##` = sections (correspondent aux section_dividers) +- Les `###` = slides individuels — leur titre EST le message clé (So What affirmatif) +- Le corps sous chaque `###` = matière textuelle brute, verbeuse, non structurée +- Les chiffres non fournis dans le brief → marqués `[à valider]` +- Les hypothèses → signalées par `*hypothèse : ...*` en italique + +--- + +## PRINCIPES NARRATIFS + +**Pyramid Principle** — commence par la conclusion. Le premier slide de contenu dit déjà tout. La suite prouve et détaille. + +**So What** — chaque `###` répond à "qu'est-ce que ça change pour l'audience ?" Son titre n'est jamais thématique ("Les chiffres clés") mais toujours affirmatif ("Les chiffres confirment l'urgence d'agir"). + +**MECE** — les sections sont mutuellement exclusives et collectivement exhaustives. Pas de répétition, pas de trou. + +**Rythme** — alterne les sections denses et les sections courtes. Une section de 5 slides denses → suivie d'une section de 1-2 slides de respiration. + +--- + +## GESTION DE LA LONGUEUR + +Une présentation de 20 minutes = 10 à 15 slides maximum. +Une présentation de 10 minutes = 6 à 10 slides. + +Si le brief est long et complexe, tu travailles en blocs de 8 `###` maximum : +- Bloc 1 : tu produis les 8 premiers, tu termines par : + `PAUSE — [N] slides restants. Réponds "continue" pour la suite.` +- Sur "continue" : tu produis le bloc suivant sans répéter ce qui précède +- Dernier bloc : tu termines par : + `FIN — [N] slides au total.` + +--- + +## RÈGLES ABSOLUES + +- Tu ne génères JAMAIS de YAML, JSON, ou toute autre structure technique +- Tu ne mentionnes JAMAIS les noms de layouts (kpi_grid, from_to…) +- Tu ne dis JAMAIS "slide X" ou "slide de type…" +- Tu n'inventes pas de données — tu marques `[à valider]` +- Tu ne te censures pas sur le contenu : la matière doit être verbeuse et complète +- Le titre de chaque `###` est une phrase affirmative, jamais un intitulé de rubrique + +--- + +## GESTION DU FEEDBACK + +Bastien peut te donner du feedback sur ta sortie. Types de retours courants : + +**"Ajoute une partie sur X"** → tu insères la nouvelle section à l'endroit logique dans la structure et tu reprouis le Markdown complet. + +**"Reformule le message de [section]"** → tu reformules uniquement le `###` concerné. + +**"Cette partie est trop technique"** → tu simplifies le corps du `###` concerné. + +**"Fusionne ces deux parties"** → tu fusionnes les deux `##` en un seul. + +Tu réponds toujours avec le Markdown **complet et à jour**, pas uniquement les modifications. + +--- + +## EXEMPLE DE SORTIE + +```markdown +# Data Governance Nordics — Vers un modèle unifié + +*Présentation au comité de direction — juin 2026* + +--- + +## Le problème est connu, mais son coût ne l'est pas + +### L'incompatibilité des systèmes coûte 2 400 jours/homme par an +Les trois filiales nordiques (Suède, Norvège, Danemark) opèrent sur des +systèmes de données distincts et non interopérables. Chaque réconciliation +manuelle mensuelle mobilise en moyenne 8 personnes pendant 3 jours par filiale, +soit 864 jours/homme annuels par filiale et 2 592 jours/homme au total [à valider]. +Ce coût n'est nulle part consolidé dans les rapports de gestion actuels. + +### Sans gouvernance commune, l'audit 2027 est en risque +La directive européenne sur la qualité des données financières (DQDF) entre +en vigueur en janvier 2027. Elle exige une traçabilité complète de bout en bout +pour les données de reporting. Avec 3 systèmes non réconciliés, PR Nordics ne +sera pas en mesure de produire la documentation requise dans les délais. +*Hypothèse : l'audit portera bien sur les données consolidées groupe et non filiale par filiale.* + +--- + +## Notre réponse : un modèle simple en 3 couches + +### Le modèle de gouvernance s'organise en 3 couches complémentaires +Couche 1 — Data Owners par domaine métier (Finance, Supply, Commercial). +Responsables de la définition et de la qualité des données dans leur périmètre. +Couche 2 — Data Stewards opérationnels. Exécutent les règles de qualité au +quotidien, assurent la réconciliation et escaladent les anomalies. +Couche 3 — Comité de gouvernance trimestriel. Arbitre les conflits de définition, +valide les évolutions du modèle, reporte au CODIR. + +### La Suède comme pilote : un périmètre maîtrisable avec un impact visible +La filiale suédoise présente le meilleur rapport complexité/visibilité pour un +pilote : équipe data déjà en place (3 personnes), système ERP commun avec le +groupe, fort engagement du directeur local. Résultats attendus en 90 jours : +réduction de 60% du temps de réconciliation mensuel [à valider]. + +--- + +## Les prochaines étapes sont claires + +### Trois décisions sont nécessaires avant fin juin +1. Valider le modèle de gouvernance avec les DG locaux (réunion à planifier) +2. Nommer les Data Owners dans chaque domaine (arbitrage RH/Métier) +3. Allouer 0,5 ETP par filiale pour les Data Stewards (budget à confirmer) +``` diff --git a/archive/v1_pipeline/render_engine.py b/archive/v1_pipeline/render_engine.py new file mode 100644 index 0000000..70b4a37 --- /dev/null +++ b/archive/v1_pipeline/render_engine.py @@ -0,0 +1,2655 @@ +""" +render_engine.py — Sliding Design System · Pernod Ricard +========================================================= +Moteur de rendu générique JSON → PPTX. + +Usage : + from render_engine import RenderEngine + engine = RenderEngine("theme.yaml", "components.yaml", "layouts.yaml") + engine.render(json_data, "output.pptx") + +Ou en ligne de commande : + python render_engine.py presentation.json output.pptx + +Architecture : + RenderEngine.render() + └- pour chaque slide : + 1. _resolve_layout() → charge la config du layout + 2. _render_background() → fond (C01) + 3. _render_signature() → logo, barre d'accent, footer (C02-C05) + 4. _measure_title() → calcule hauteur réelle du titre + 5. _render_title() → place le titre (C02) + 6. pour chaque content_zone : + _measure_component() → hauteur réelle + _render_component() → dispatch vers le bon renderer +""" + +from __future__ import annotations + +import json +import math +import os +import sys +from pathlib import Path +from typing import Any + +import yaml +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.text import PP_ALIGN +from pptx.util import Cm, Pt, Emu +from pptx.dml.color import RGBColor +from pptx.oxml.ns import qn +from lxml import etree + + +# - +# CONSTANTES +# - + +CM = 360000 # 1 cm = 360 000 EMU +PT = 12700 # 1 pt = 12 700 EMU +SLIDE_W = 12192000 # 33.87 cm +SLIDE_H = 6858000 # 19.05 cm +FOOTER_TOP = 18.35 # cm +FOOTER_H = 0.70 # cm + + +# - +# UTILITAIRES +# - + +def cm(v: float) -> int: + """Centimètres → EMU.""" + return int(v * CM) + + +def pt(v: float) -> int: + """Points → EMU (pour line_spacing, etc.).""" + return int(v * PT) + + +def hex_to_rgb(h: str) -> RGBColor: + """'#rrggbb' → RGBColor.""" + h = h.lstrip("#") + return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) + + +def resolve_ref(value: str, theme: dict) -> str: + """ + Résout une référence theme.* dans une valeur YAML. + Ex: 'theme.colors.primary.rose' → '#ff9166' + Retourne la valeur brute si ce n'est pas une ref. + """ + if not isinstance(value, str) or not value.startswith("theme."): + return value + parts = value.split(".")[1:] # retire 'theme' + node = theme + for p in parts: + if isinstance(node, dict) and p in node: + node = node[p] + else: + return value # ref non résolue → retourne telle quelle + return node + + +def add_text_box(slide, left, top, width, height, + text, font_name, font_size_pt, bold=False, italic=False, + color="#000000", align=PP_ALIGN.LEFT, word_wrap=True): + """Ajoute une text box sur le slide. Retourne le shape.""" + txBox = slide.shapes.add_textbox(cm(left), cm(top), cm(width), cm(height)) + tf = txBox.text_frame + tf.word_wrap = word_wrap + p = tf.paragraphs[0] + p.alignment = align + run = p.add_run() + run.text = text + run.font.name = font_name + run.font.size = Pt(font_size_pt) + run.font.bold = bold + run.font.italic = italic + run.font.color.rgb = hex_to_rgb(color) + return txBox + + +def add_rect(slide, left, top, width, height, fill_color, border_color=None, border_width_cm=0): + """Ajoute un rectangle plein. Retourne le shape.""" + shape = slide.shapes.add_shape( + 1, # MSO_SHAPE_TYPE.RECTANGLE + cm(left), cm(top), cm(width), cm(height) + ) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(fill_color) + if border_color and border_width_cm > 0: + shape.line.color.rgb = hex_to_rgb(border_color) + shape.line.width = cm(border_width_cm) + else: + shape.line.fill.background() + return shape + + +def add_line(slide, x1, y1, x2, y2, color="#e8e2d6", width_cm=0.03): + """Ajoute une ligne.""" + from pptx.util import Emu + connector = slide.shapes.add_connector(1, cm(x1), cm(y1), cm(x2), cm(y2)) + connector.line.color.rgb = hex_to_rgb(color) + connector.line.width = cm(width_cm) + return connector + + +def estimate_text_height(text: str, font_size_pt: float, + box_width_cm: float, line_spacing: float = 1.15) -> float: + """ + Estime la hauteur en cm d'un texte dans une boîte. + Heuristique : ~2.2 caractères par cm de largeur à 11pt, scaled par font_size. + """ + chars_per_line = max(1, int(box_width_cm * 2.2 * (11 / font_size_pt))) + lines = 0 + for paragraph in text.split("\n"): + if not paragraph.strip(): + lines += 0.5 + continue + lines += math.ceil(len(paragraph) / chars_per_line) + line_height_cm = font_size_pt * 0.035 * line_spacing + return lines * line_height_cm + + +# - +# RENDER ENGINE +# - + +class RenderEngine: + """ + Moteur principal. Charge les 3 YAML, expose render(json_data, output_path). + """ + + def __init__(self, theme_path: str, components_path: str, layouts_path: str): + with open(theme_path, encoding="utf-8") as f: + self.theme = yaml.safe_load(f) + with open(components_path, encoding="utf-8") as f: + self.components = yaml.safe_load(f)["components"] + with open(layouts_path, encoding="utf-8") as f: + data = yaml.safe_load(f) + self.layouts = data["layouts"] + + # Polices résolues (avec fallback si non installées) + self._font_display = self._resolve_font("display") + self._font_body = self._resolve_font("body") + + # Cycle couleur (index global, remis à zéro par présentation) + self._cycle_index = 0 + + # - Résolution des polices - + + def _resolve_font(self, role: str) -> str: + font_cfg = self.theme["typography"][role] + primary = font_cfg["family"] + fallback = font_cfg.get("fallback", "Arial") + if self._is_font_available(primary): + return primary + return fallback + + def _is_font_available(self, font_name: str) -> bool: + fonts_dir = Path(self.theme["assets"].get("fonts_path", "assets/fonts/")) + if not fonts_dir.exists(): + return False + fn = font_name.lower().replace(" ", "") + return any(fn in f.stem.lower().replace(" ", "").replace("-", "") for f in fonts_dir.iterdir()) + + def patch_pptx_theme(self, output_path: str): + import zipfile as _zf, shutil as _sh + from lxml import etree as _et + tmp = output_path + ".tmp" + ns = "http://schemas.openxmlformats.org/drawingml/2006/main" + zin = _zf.ZipFile(output_path, "r") + zout = _zf.ZipFile(tmp, "w", _zf.ZIP_DEFLATED) + for item in zin.infolist(): + if item.filename != "ppt/theme/theme1.xml": + zout.writestr(item.filename, zin.read(item.filename)) + data = zin.read("ppt/theme/theme1.xml") + root = _et.fromstring(data) + fs = root.find(f".//{{{ns}}}fontScheme") + if fs is not None: + for tag, font in [("majorFont", self._font_display), ("minorFont", self._font_body)]: + node = fs.find(f"{{{ns}}}{tag}") + if node is not None: + lat = node.find(f"{{{ns}}}latin") + if lat is not None: + lat.set("typeface", font) + zout.writestr("ppt/theme/theme1.xml", _et.tostring(root, encoding="unicode").encode("utf-8")) + zin.close() + zout.close() + _sh.move(tmp, output_path) + + def _font(self, role: str) -> str: + return self._font_display if role == "display" else self._font_body + + # - Résolution des refs theme - + + def _r(self, value: Any) -> Any: + """Résout une ref theme.* si nécessaire.""" + return resolve_ref(value, self.theme) + + def _cycle_color(self) -> str: + colors = self.theme["colors"]["cycle"] + c = colors[self._cycle_index % len(colors)] + self._cycle_index += 1 + return c + + # - Mesure - + + def _measure_title(self, layout_cfg: dict, slide_data: dict) -> float: + """ + Calcule la hauteur réelle occupée par le bloc titre + sous-titre. + Retourne la hauteur en cm. + """ + tz = layout_cfg.get("title_zone") + if not tz: + return 0.0 + + titre = slide_data.get("titre", "") + sous_titre = slide_data.get("sous_titre", "") + + font_override = tz.get("font_override", {}) + size_pt = font_override.get("size_pt", + self.theme["typography"]["sizes"]["slide_title"]) + width_cm = tz.get("width_cm", 30.0) + + h = estimate_text_height(titre, size_pt, width_cm, 1.1) + if sous_titre: + sub_size = tz.get("subtitle", {}).get("size_pt", + self.theme["typography"]["sizes"]["slide_subtitle"]) + h += estimate_text_height(sous_titre, sub_size, width_cm, 1.1) + h += 0.15 # marge entre titre et sous-titre + return max(h, 0.60) # minimum 0.6 cm + + def _measure_component(self, zone: dict, slide_data: dict) -> float: + """ + Estime la hauteur réelle d'une content_zone selon son contenu. + Retourne la hauteur en cm. Si non estimable, retourne height_cm du layout. + """ + comp_id = zone.get("component", "") + max_h = zone.get("height_cm", 14.0) + + # bullet_list + if comp_id == "C06": + bullets = slide_data.get("bullets", []) + if not bullets: + # cherche dans les sous-clés (two_cols, etc.) + return max_h + total_h = 0.0 + for b in bullets: + lvl = b.get("niveau", 1) + size_pt = [20, 18, 16][min(lvl - 1, 2)] + w = zone.get("width_cm", 28.0) + total_h += estimate_text_height( + b.get("texte", ""), size_pt, w - (lvl - 1) * 0.5) + total_h += [0.14, 0.08, 0.04][min(lvl - 1, 2)] + return min(total_h + 0.3, max_h) + + # text_paragraph (executive_summary blocs) + if comp_id == "C07": + # cherche le champ associé dans slide_data + for key in ["situation", "complication", "resolution", + "contenu", "description"]: + if key in slide_data: + txt = slide_data[key] + h = estimate_text_height(txt, 11, zone.get("width_cm", 28.0)) + return min(h + 0.6, max_h) # +0.6 pour le titre de bloc + return max_h + + # kpi_grid → hauteur calculée selon nb items + if comp_id == "C09": + items = slide_data.get("items", []) + n = len(items) + grid = {2: (2, 1), 3: (3, 1), 4: (2, 2), 5: (3, 2), 6: (3, 2)} + cols, rows = grid.get(n, (3, 2)) + card_h = 4.5 # hauteur d'une carte KPI en cm + gap = 0.4 + return min(rows * card_h + (rows - 1) * gap, max_h) + + # big_stat → hauteur fixe + if comp_id == "C10": + return max_h + + # Pour tous les autres composants visuels complexes → hauteur max + return max_h + + # - Rendu principal - + + def render(self, json_data: dict | str, output_path: str): + """ + Point d'entrée. Accepte un dict ou une chaîne JSON. + Produit le fichier PPTX à output_path. + """ + if isinstance(json_data, str): + json_data = json.loads(json_data) + + prs = Presentation() + prs.slide_width = Emu(SLIDE_W) + prs.slide_height = Emu(SLIDE_H) + + # Supprime les layouts par défaut (on dessine tout manuellement) + blank_layout = prs.slide_layouts[6] # layout "blank" + + self._cycle_index = 0 + slides = json_data.get("slides", []) + + for i, slide_data in enumerate(slides): + slide = prs.slides.add_slide(blank_layout) + layout_name = slide_data.get("layout", "default_bullets") + self._render_slide(slide, slide_data, layout_name, i + 1, len(slides)) + + prs.save(output_path) + self.patch_pptx_theme(output_path) + print(f"✓ PPTX généré : {output_path} ({len(slides)} slides)") + + def _render_slide(self, slide, slide_data: dict, layout_name: str, + slide_num: int, total: int): + """Orchestre le rendu d'un slide complet.""" + layout_cfg = self.layouts.get(layout_name) + if not layout_cfg: + print(f" ⚠ Layout inconnu '{layout_name}' → fallback default_bullets") + layout_cfg = self.layouts["default_bullets"] + layout_name = "default_bullets" + + # - 1. Background - + self._render_background(slide, layout_cfg) + + # - 2. Signature (footer, logo, accent bar) - + self._render_footer(slide, layout_name, slide_num) + self._render_logo(slide, layout_name) + + # - 3. Titre + mesure - + if layout_name == "section_divider": + self._render_section_divider_title(slide, slide_data) + return + title_h = self._measure_title(layout_cfg, slide_data) + title_bottom = self._render_title(slide, layout_cfg, slide_data, title_h) + # Barre orange = hauteur fixe du title_zone (pas l'estimation) + _tz = (layout_cfg or {}).get("title_zone") or {} + _bar_h = _tz.get("height_cm", title_h) + self._render_accent_bar(slide, layout_name, _bar_h) + + # - 4. Content zones - + + # Rendu specialise pour executive_summary + if layout_name == "executive_summary": + self._render_executive_summary(slide, slide_data, title_bottom) + return + + # Rendu specialise circular_diagram (evite le double rendu circle+legend) + if layout_name == "circular_diagram": + self._render_circular_diagram_full(slide, slide_data, layout_cfg, title_bottom) + return + # Rendu specialise two_cols_text + if layout_name == "two_cols_text": + self._render_two_cols_text(slide, slide_data, layout_cfg, title_bottom) + return + # Rendu specialise recommendation_card + if layout_name == "recommendation_card": + avail_h = FOOTER_TOP - 0.30 + zone_fake = {"id": "rec_sidebar", "component": "C26", "width_cm": 8.0} + self._render_recommendation_sidebar(slide, zone_fake, slide_data, + 0.0, 0.0, 8.0, avail_h) + return + + + zones = layout_cfg.get("content_zones") or [] + # cursor : commence juste sous le titre + cursor_y = title_bottom + 0.20 if title_bottom else 2.80 + # zone max disponible (jusqu'au footer ou bas du slide) + max_bottom = FOOTER_TOP - 0.30 # laisse 0.3 cm au-dessus du footer + + for zone in zones: + # Zones optionnelles absentes du JSON → skip + if zone.get("optional") and not self._zone_has_data(zone, slide_data): + continue + + # Positions : priorité aux coords fixes, sinon on utilise le curseur + z_left = zone.get("left_cm", 1.50) + z_top = zone.get("top_cm", cursor_y) + z_width = zone.get("width_cm", 30.87) + + # Calcul de la hauteur réelle + measured_h = self._measure_component(zone, slide_data) + z_height = min(measured_h, max_bottom - z_top) + if z_height <= 0: + continue # plus de place + + # Mise à jour du curseur (uniquement pour les zones sans top fixe) + if "top_cm" not in zone: + cursor_y = z_top + z_height + 0.25 + + self._render_zone(slide, zone, slide_data, + z_left, z_top, z_width, z_height) + + # - Background - + + def _render_background(self, slide, layout_cfg: dict): + """Rend le fond du slide (C01).""" + bg = layout_cfg.get("background", {}) + color = self._r(bg.get("color", "#ffffff")) + + if bg.get("diagonal_split"): + color_right = self._r(bg.get("color_right", "#023466")) + angle = bg.get("diagonal_angle_deg", 15) + self._render_diagonal_background(slide, color, color_right, angle) + else: + from pptx.dml.color import RGBColor as _RGBBG + fill = slide.background.fill + fill.solid() + fill.fore_color.rgb = _RGBBG( + int(color[1:3], 16), + int(color[3:5], 16), + int(color[5:7], 16) + ) + + def _render_diagonal_background(self, slide, color_left: str, + color_right: str, angle_deg: float = 0): + """ + Fond section_divider : 2 rectangles. + Gauche 2/3 slide (dark_blue), droite 1/3 slide (blanc). + angle_deg ignore -- conserve pour compatibilite. + """ + W = 33.87 + H = 19.05 + left_w = W * 2 / 3 + right_w = W * 1 / 3 + add_rect(slide, 0, 0, left_w, H, color_left) + add_rect(slide, left_w, 0, right_w, H, color_right) + + def _diagonal_max_x(self, y_cm: float, angle_deg: float = 15) -> float: + """ + x maximum disponible dans le panneau sombre a la position y_cm. + Interpolation lineaire entre (0, split_x) et (H, split_x_bottom). + """ + split_x = 18.50 + H = 19.05 + offset = H * math.tan(math.radians(angle_deg)) + split_x_bottom = split_x - offset + frac = min(max(y_cm / H, 0), 1) + return split_x + frac * (split_x_bottom - split_x) - 0.80 + + # - Signature - + + def _render_footer(self, slide, layout_name: str, slide_num: int): + """Rend le footer PR (C04).""" + footer_cfg = self.theme["signature"]["footer"] + hidden_on = footer_cfg.get("hidden_on", []) + if layout_name in hidden_on: + return + + top = FOOTER_TOP + h = FOOTER_H + w = 33.87 + + # Fond blanc + add_rect(slide, 0, top, w, h, "#ffffff") + # Bordure top + add_line(slide, 0, top, w, top, "#e8e2d6", 0.03) + + # Numéro de slide + add_text_box(slide, 0.80, top + 0.10, 1.50, 0.50, + str(slide_num), self._font_body, 8, + color="#000a32", align=PP_ALIGN.LEFT) + + # Séparateur vertical + add_line(slide, 1.50, top + 0.10, 1.50, top + 0.60, "#7fa5d0", 0.03) + + # "Pernod Ricard" + add_text_box(slide, 1.70, top + 0.10, 5.00, 0.50, + "Pernod Ricard", self._font_body, 8, + color="#000a32", align=PP_ALIGN.LEFT) + + # Tagline à droite + add_text_box(slide, 15.00, top + 0.10, 18.00, 0.50, + "DATA GOVERNANCE DATA MANAGEMENT", + self._font_body, 7, + color="#48545a", align=PP_ALIGN.RIGHT) + + def _render_logo(self, slide, layout_name: str): + """Insère le logo PR top-left si le fichier assets/logo_pr_sun.png existe.""" + logo_cfg = self.theme["signature"]["logo_topbar"] + visible_on = logo_cfg.get("visible_on", []) + if layout_name not in visible_on: + return + + logo_path = logo_cfg.get("file", "assets/logo_pr_sun.png") + if not os.path.exists(logo_path): + return # Logo absent -> rien affiche + + slide.shapes.add_picture( + logo_path, + cm(logo_cfg["position_left_cm"]), + cm(logo_cfg["position_top_cm"]), + height=cm(logo_cfg["height_cm"]) + ) + + def _render_accent_bar(self, slide, layout_name: str, title_h: float): + """Barre verticale rose à gauche du titre (C03).""" + sig = self.theme["signature"]["accent_bar"] + if layout_name not in sig.get("visible_on", []): + return + + bar_h = max(title_h, 0.40) # proportionne au titre, pas de minimum arbitraire + add_rect(slide, + sig["position_left_cm"], 0.45, + sig["width_cm"], bar_h, + sig["color"]) + + # - Titre - + + def _render_title(self, slide, layout_cfg: dict, + slide_data: dict, title_h: float) -> float: + """ + Rend le titre et le sous-titre. + Layouts diagonaux : une textbox, largeur max entre marges, + centrage vertical natif MSO_ANCHOR.MIDDLE a slide_height/2. + Autres layouts : comportement standard. + Retourne le y_bottom en cm. + """ + tz = layout_cfg.get("title_zone") + if not tz: + return 0.0 + + titre = slide_data.get("titre", "") + sous_titre = slide_data.get("sous_titre", "") + + font_override = tz.get("font_override", {}) + size_pt = font_override.get("size_pt", + self.theme["typography"]["sizes"]["slide_title"]) + bold = font_override.get("bold", True) + color = self._r(font_override.get("color", + self.theme["colors"]["text"]["on_white"])) + + bg = layout_cfg.get("background", {}) + is_diagonal = bg.get("diagonal_split", False) + + # - Layouts diagonaux - + if is_diagonal: + from pptx.enum.text import MSO_ANCHOR + + SLIDE_W = 33.87 + SLIDE_H = 19.05 + # Marges statiques egales gauche et droite + margin = self.theme.get("spacing", {}).get("slide_margin_cm", 1.80) + sub_size = max(int(size_pt * 0.55), 14) + + # Largeur = slide - 2 marges. Aucune contrainte diagonale. + width = SLIDE_W - 2 * margin + + # Textbox centree sur la moitie du slide + box_h = SLIDE_H * 0.40 # hauteur genereusse (40% slide) + box_top = SLIDE_H / 2 - box_h / 2 # centre sur slide_h / 2 + + txBox = slide.shapes.add_textbox( + cm(margin), cm(box_top), + cm(width), cm(box_h) + ) + tf = txBox.text_frame + tf.word_wrap = True + tf.auto_size = None + # Centrage vertical natif : le texte est centre dans la boite + tf.vertical_anchor = MSO_ANCHOR.MIDDLE + + # Paragraphe titre uniquement dans cette textbox + p = tf.paragraphs[0] + p.alignment = PP_ALIGN.LEFT + run = p.add_run() + run.text = titre + run.font.name = self._font_display + run.font.size = Pt(size_pt) + run.font.bold = bold + run.font.color.rgb = hex_to_rgb(color) + + titre_bottom = box_top + box_h + + # Sous-titre : textbox independante centree entre bas du titre et bas du slide + if sous_titre: + sub_color = self.theme["colors"]["primary"]["rose"] + sub_size = max(int(size_pt * 0.55), 14) + zone_top = titre_bottom + zone_bot = SLIDE_H - FOOTER_H - 0.30 + sub_h = sub_size * 0.038 * 2.5 # hauteur genereusse pour 2 lignes max + sub_top = zone_top + (zone_bot - zone_top) / 2 - sub_h / 2 + + txBox2 = slide.shapes.add_textbox( + cm(margin), cm(sub_top), + cm(width), cm(sub_h) + ) + tf2 = txBox2.text_frame + tf2.word_wrap = True + tf2.auto_size = None + tf2.vertical_anchor = MSO_ANCHOR.MIDDLE + p2 = tf2.paragraphs[0] + p2.alignment = PP_ALIGN.LEFT + run2 = p2.add_run() + run2.text = sous_titre + run2.font.name = self._font_display + run2.font.size = Pt(sub_size) + run2.font.bold = False + run2.font.italic = True + run2.font.color.rgb = hex_to_rgb(sub_color) + + return titre_bottom + + # Layouts standards + from pptx.enum.text import MSO_ANCHOR as _MSO_STD + left = tz.get("left_cm", 1.80) + top = tz.get("top_cm", 0.45) + width = tz.get("width_cm", 30.00) + height_cm = tz.get("height_cm", 1.90) + + # Police 28pt par defaut sauf font_override explicite + if "size_pt" not in font_override: + size_pt = 28 + + txBox = slide.shapes.add_textbox( + cm(left), cm(top), cm(width), cm(height_cm)) + tf = txBox.text_frame + tf.word_wrap = True + tf.auto_size = None + tf.vertical_anchor = _MSO_STD.MIDDLE + p = tf.paragraphs[0] + p.alignment = PP_ALIGN.LEFT + run = p.add_run() + run.text = titre + run.font.name = self._font_display + run.font.size = Pt(size_pt) + run.font.bold = bold + run.font.color.rgb = hex_to_rgb(color) + + current_bottom = top + height_cm + + if sous_titre: + sub_cfg = tz.get("subtitle", {}) + sub_size = sub_cfg.get("size_pt", + self.theme["typography"]["sizes"]["slide_subtitle"]) + sub_color = self._r(sub_cfg.get("color", + self.theme["colors"]["text"]["subtitle"])) + sub_size = max(int(size_pt * 0.55), 14) + margin_top = sub_cfg.get("margin_top_cm", 0.25) + sub_top = current_bottom + margin_top + max_bottom_sub = 19.05 - FOOTER_H - 0.50 + if sub_top + 0.90 > max_bottom_sub: + sub_top = max_bottom_sub - 0.90 + add_text_box(slide, left, sub_top, width, 0.90, + sous_titre, self._font_body, sub_size, + color=sub_color) + current_bottom = sub_top + 0.90 + + return current_bottom + + def _zone_has_data(self, zone: dict, slide_data: dict) -> bool: + """Vérifie si une zone optionnelle a des données dans le JSON.""" + comp = zone.get("component", "") + if comp == "C07": + return any(k in slide_data for k in + ["description", "situation", "complication", "resolution", "contenu"]) + return True + + def _render_zone(self, slide, zone: dict, slide_data: dict, + left: float, top: float, width: float, height: float): + """Dispatche vers le renderer du composant.""" + comp = zone.get("component", "") + zone_type = zone.get("type", "") + + # Séparateurs (pas de composant associé) + if zone_type == "vertical_line": + add_line(slide, zone.get("x_cm", left), + zone.get("top_cm", top), + zone.get("x_cm", left), + zone.get("top_cm", top) + zone.get("height_cm", height), + self._r(zone.get("color", "#e8e2d6")), + zone.get("width_cm", 0.03)) + return + if zone_type == "horizontal_line": + y = zone.get("y_cm", top) + add_line(slide, left, y, left + width, y, + self._r(zone.get("color", "#e8e2d6")), + zone.get("width_cm", 0.03)) + return + + if zone_type == "section_circle": + self._render_section_circle(slide, zone) + return + + dispatch = { + "C06": self._render_bullet_list, + "C07": self._render_text_paragraph, + "C08": self._render_quote_block, + "C09": self._render_kpi_grid, + "C10": self._render_big_stat, + "C11": self._render_data_table, + "C12": self._render_chart_placeholder, + "C13": self._render_callout_box, + "C14": self._render_benchmark, + "C15": self._render_matrix_2x2, + "C16": self._render_pyramid, + "C17": self._render_circular_diagram, + "C18": self._render_from_to_pairs, + "C19": self._render_numbered_steps, + "C20": self._render_chevrons, + "C21": self._render_gantt, + "C22": self._render_timeline, + "C23": self._render_org_chart, + "C24": self._render_raci, + "C25": self._render_decision_tree, + "C26": self._render_recommendation_sidebar, + } + + renderer = dispatch.get(comp) + if renderer: + renderer(slide, zone, slide_data, left, top, width, height) + else: + # Composant inconnu → zone grise placeholder + self._render_placeholder(slide, left, top, width, height, comp) + + # - Renderers des composants - + + def _render_placeholder(self, slide, left, top, width, height, label="?"): + """Zone placeholder silencieuse — rien n'est affiche.""" + pass + + def _render_section_circle(self, slide, zone: dict): + """ + Cercle numerote pour section_divider. + Centre sur la separation (x=2/3 slide), centre vertical slide. + Contour blanc pour visibilite sur fond bleu. + """ + from pptx.dml.color import RGBColor as _RGB2 + + SLIDE_W = 33.87 + SLIDE_H = 19.05 + numero = str(zone.get("text", zone.get("numero", "1"))) + d = zone.get("diameter_cm", 2.80) + fill = self._r(zone.get("fill", + self.theme["colors"]["primary"]["dark_blue"])) + size_pt = zone.get("size_pt", 54) + + cx = SLIDE_W * 2 / 3 # centre sur la separation + cy = SLIDE_H / 2 # centre vertical + + shape = slide.shapes.add_shape(9, + cm(cx - d/2), cm(cy - d/2), cm(d), cm(d)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(fill) + from pptx.util import Pt as _Pt2 + from pptx.dml.color import RGBColor as _RGB3 + shape.line.color.rgb = _RGB3(0xFF, 0xFF, 0xFF) + shape.line.width = int(0.20 * CM) + + add_text_box(slide, + cx - d/2, cy - d/2 - 0.10, + d, d + 0.10, + numero, self._font_display, size_pt, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + + # C06 — bullet_list - + + def _render_section_divider_title(self, slide, slide_data: dict): + """ + Titre de section_divider : + - Dans le bloc bleu gauche (0 -> 2/3 slide) + - Marges laterales identiques gauche et droite + - S'arrete avant le rond (securite d/2 + 0.5 cm) + - Centre verticalement via MSO_ANCHOR.MIDDLE + """ + from pptx.enum.text import MSO_ANCHOR as _MSO_SD + + SLIDE_W = 33.87 + SLIDE_H = 19.05 + titre = slide_data.get("titre", "") + if not titre: + return + + margin = 1.80 + d_rond = 2.80 + marge_securite = 0.50 + max_x_titre = (SLIDE_W * 2/3) - (d_rond/2) - marge_securite + width = max_x_titre - margin + size_pt = 40 + box_h = SLIDE_H * 0.50 + box_top = SLIDE_H / 2 - box_h / 2 + + txBox = slide.shapes.add_textbox( + cm(margin), cm(box_top), + cm(width), cm(box_h)) + tf = txBox.text_frame + tf.word_wrap = True + tf.auto_size = None + tf.vertical_anchor = _MSO_SD.MIDDLE + p = tf.paragraphs[0] + p.alignment = PP_ALIGN.LEFT + run = p.add_run() + run.text = titre + run.font.name = self._font_display + run.font.size = Pt(size_pt) + run.font.bold = True + run.font.color.rgb = hex_to_rgb("#ffffff") + + # Rond numero -- appel direct avec numero_section du YAML + numero = str(slide_data.get("numero_section", "")) + if numero: + zone_circle = { + "text": numero, + "diameter_cm": 2.80, + "size_pt": 54, + "fill": self.theme["colors"]["primary"]["dark_blue"], + } + self._render_section_circle(slide, zone_circle) + + def _render_two_cols_text(self, slide, slide_data: dict, + layout_cfg: dict, title_bottom: float): + """ + Renderer specialise two_cols_text avec centrage vertical dynamique. + - Calcule la hauteur reelle de chaque colonne + - Centre la plus haute dans la zone utile + - Aligne le haut de la plus petite sur le haut de la plus haute centree + """ + FOOTER_TOP = 18.35 + zone_top = title_bottom + 0.60 # marge sous le titre slide + zone_bot = FOOTER_TOP - 0.30 + zone_h = zone_bot - zone_top + + # Recuperer les zones depuis le layout + zones = layout_cfg.get("content_zones", []) + z_left = next((z for z in zones if z.get("id") == "col_left"), {}) + z_right = next((z for z in zones if z.get("id") == "col_right"), {}) + z_sep = next((z for z in zones if z.get("type") == "vertical_line"), None) + + left_x = z_left.get("left_cm", 1.50) + left_w = z_left.get("width_cm", 14.80) + right_x = z_right.get("left_cm", 17.50) + right_w = z_right.get("width_cm", 14.87) + + # Donnees des colonnes + data_l = slide_data.get("left", {}) + data_r = slide_data.get("right", {}) + + titre_size = 26 + body_size = 16 + titre_h = 0.90 + gap = 1.40 # marge titre -> texte + + def col_height(data): + titre = data.get("titre", "") if isinstance(data, dict) else "" + texte = data.get("contenu", "") if isinstance(data, dict) else str(data) + h = 0 + if titre: + h += titre_h + gap + if texte: + h += estimate_text_height(texte, body_size, left_w, 1.2) + h = max(h, h + 0.20) + return h + + h_left = col_height(data_l) + h_right = col_height(data_r) + + # Centrage de la plus haute, alignement haut de la plus petite + if h_left >= h_right: + # Centrer gauche, aligner droite sur son haut + top_left = zone_top + max(0, (zone_h - h_left) / 2) + top_right = top_left + else: + # Centrer droite, aligner gauche sur son haut + top_right = zone_top + max(0, (zone_h - h_right) / 2) + top_left = top_right + + # Renderer chaque colonne + def render_col(data, x, w, y_start, titre_couleur): + cur = y_start + titre = data.get("titre", "") if isinstance(data, dict) else "" + texte = data.get("contenu", "") if isinstance(data, dict) else str(data) + if titre: + add_text_box(slide, x, cur, w, titre_h, + titre, self._font_body, titre_size, + bold=True, color=titre_couleur, + align=PP_ALIGN.CENTER) + cur += gap + if texte: + if isinstance(texte, str): + lines_clean = [l.strip().lstrip("- ").strip() + for l in texte.splitlines() if l.strip()] + texte = "\n".join(lines_clean) + rem_h = max(0.5, zone_bot - cur) + add_text_box(slide, x, cur, w, rem_h, + texte, self._font_body, body_size, + color=self.theme["colors"]["text"]["body"]) + + color_left = self.theme["colors"]["primary"]["bright_blue"] + color_right = self.theme["colors"]["primary"]["rose"] + render_col(data_l, left_x, left_w, top_left, color_left) + render_col(data_r, right_x, right_w, top_right, color_right) + + # Barre separatrice verticale — hauteur = bloc le plus haut + buffer + if z_sep is not None: + sep_x = z_sep.get("x_cm", 16.935) + sep_top = min(top_left, top_right) + sep_bot = max(top_left + h_left, top_right + h_right) + 0.60 + sep_bot = min(sep_bot, zone_bot) + add_line(slide, sep_x, sep_top, sep_x, sep_bot, + self._r(z_sep.get("color", "#e8e2d6")), + z_sep.get("width_cm", 0.03)) + + def _render_bullet_list(self, slide, zone, slide_data, + left, top, width, height, no_bold=False): + """Bullets hiérarchisés L1/L2/L3.""" + # Cherche les bullets dans le JSON (champ direct ou dans une colonne) + zone_id = zone.get("id", "") + if "col_left" in zone_id: + col_data = slide_data.get("left", {}) + elif "col_right" in zone_id: + col_data = slide_data.get("right", {}) + else: + col_data = slide_data + + bullets = col_data.get("bullets", []) + if not bullets: + return + + # Centrage vertical dynamique + _pt2cm = 0.03528 + _sz = {1: (20+4)*_pt2cm, 2: (18+2)*_pt2cm, 3: (16+1)*_pt2cm} + _content_h = sum(_sz.get(b.get('niveau', 1), _sz[1]) for b in bullets) + _content_h = min(_content_h, height) + _voff = max(0.0, (height - _content_h) / 2) + txBox = slide.shapes.add_textbox( + cm(left), cm(top + _voff), cm(width), cm(_content_h)) + tf = txBox.text_frame + tf.word_wrap = True + + sizes = {1: 20, 2: 18, 3: 16} + colors = { + 1: "#000a32", + 2: self.theme["colors"]["text"]["body"], + 3: self.theme["colors"]["text"]["body"], + } + indents = {1: 0, 2: 0.5, 3: 1.0} + markers = {1: "• ", 2: "– ", 3: "▪ "} + space_before = {1: Pt(4), 2: Pt(2), 3: Pt(1)} + + # Calcul padding haut pour centrage vertical + _pt_per_lvl = {1: 24, 2: 20, 3: 17} + _content_pt = sum(_pt_per_lvl.get(b.get('niveau', 1), 24) for b in bullets) + _zone_pt = height * 28.35 + _top_padding_pt = max(0.0, (_zone_pt - _content_pt) / 2) + first = True + for b in bullets: + lvl = b.get("niveau", 1) + text = b.get("texte", "") + + p = tf.paragraphs[0] if first else tf.add_paragraph() + if first: + # Centrage vertical : espace haut = (zone_reelle - contenu) / 2 + _real_zone_pt = (FOOTER_TOP - 0.30 - top) * 28.35 + _pt_per_lvl = {1: 24, 2: 20, 3: 17} + _content_pt = sum(_pt_per_lvl.get(b.get('niveau', 1), 24) for b in bullets) + _pad = max(0.0, (_real_zone_pt - _content_pt) / 2) + print(f"[bullet debug] top={top:.2f} FOOTER_TOP={FOOTER_TOP:.2f} _real_zone_pt={_real_zone_pt:.1f}pt _content_pt={_content_pt:.1f}pt _pad={_pad:.1f}pt") + p.space_before = Pt(_pad) + first = False + else: + p.space_before = space_before.get(lvl, Pt(4)) + p.alignment = PP_ALIGN.LEFT + + # Indentation via l'XML (level) + pPr = p._p.get_or_add_pPr() + pPr.set("lvl", str(lvl - 1)) + + run = p.add_run() + run.text = markers[lvl] + text + run.font.name = self._font_body + run.font.size = Pt(sizes[lvl]) + run.font.bold = (lvl == 1) and not no_bold + run.font.color.rgb = hex_to_rgb(colors[lvl]) + + # Sous-items récursifs + for sub in b.get("sous_items", []) or []: + p2 = tf.add_paragraph() + p2.alignment = PP_ALIGN.LEFT + run2 = p2.add_run() + run2.text = " – " + sub + run2.font.name = self._font_body + run2.font.size = Pt(9) + run2.font.color.rgb = hex_to_rgb(self.theme["colors"]["text"]["body"]) + + # C07 variant — executive_summary - + + def _render_executive_summary(self, slide, slide_data: dict, + title_bottom: float): + """ + Rendu specialise pour le layout executive_summary (L09). + 3 blocs SCR equidistants verticalement entre bas du titre et footer. + Chaque bloc = label colore + description en une seule textbox. + Pas de box de fond — texte direct sur fond blanc. + """ + from pptx.enum.text import MSO_ANCHOR + + SLIDE_W = 33.87 + FOOTER_TOP = 18.35 + margin = self.theme.get("spacing", {}).get("slide_margin_cm", 1.80) + box_w = SLIDE_W - 2 * margin + + # Donnees SCR + blocs = [ + { + "label": "Situation", + "champ": "situation", + "couleur": self.theme["colors"]["primary"]["dark_blue"], + }, + { + "label": "Complication", + "champ": "complication", + "couleur": self.theme["colors"]["primary"]["rose"], + }, + { + "label": "Resolution", + "champ": "resolution", + "couleur": self.theme["colors"]["primary"]["bright_blue"], + }, + ] + + # Tailles de police + label_size = 18 # label SCR + desc_size = 14 # description + + # Zone utile : sous le titre jusqu'au footer + zone_top = title_bottom + 0.30 + zone_bot = FOOTER_TOP - 0.50 + zone_h = zone_bot - zone_top + + # Hauteur de chaque bloc (label + description + gap interne) + # On alloue 1/3 de la zone a chaque bloc + bloc_h = zone_h / 3 + + for i, bloc in enumerate(blocs): + texte = slide_data.get(bloc["champ"], "") + if not texte: + continue + + # Position verticale : equidistance + y = zone_top + i * bloc_h + + # Une seule textbox : label (bold) + newline + description + txBox = slide.shapes.add_textbox( + cm(margin), cm(y), + cm(box_w), cm(bloc_h - 0.10) + ) + tf = txBox.text_frame + tf.word_wrap = True + tf.auto_size = None + tf.vertical_anchor = MSO_ANCHOR.MIDDLE + + # Paragraphe 1 : label + p1 = tf.paragraphs[0] + p1.alignment = PP_ALIGN.LEFT + run1 = p1.add_run() + run1.text = bloc["label"] + run1.font.name = self._font_body + run1.font.size = Pt(label_size) + run1.font.bold = True + run1.font.color.rgb = hex_to_rgb(bloc["couleur"]) + + # Paragraphe 2 : description + p2 = tf.add_paragraph() + p2.alignment = PP_ALIGN.LEFT + p2.space_before = Pt(2) + run2 = p2.add_run() + run2.text = texte + run2.font.name = self._font_body + run2.font.size = Pt(desc_size) + run2.font.bold = False + run2.font.color.rgb = hex_to_rgb( + self.theme["colors"]["text"]["body"]) + + # Separateur horizontal entre blocs (sauf le dernier) + if i < len(blocs) - 1: + sep_y = y + bloc_h + add_line(slide, margin, sep_y, SLIDE_W - margin, sep_y, + "#e8e2d6", 0.03) + + # C07 — text_paragraph - + + def _render_text_paragraph(self, slide, zone, slide_data, + left, top, width, height): + """Bloc de texte libre avec titre de bloc optionnel.""" + zone_id = zone.get("id", "") + + # Mapping zone_id → champ JSON + field_map = { + "bloc_situation": ("Situation", "situation"), + "bloc_complication": ("Complication", "complication"), + "bloc_resolution": ("Résolution", "resolution"), + "col_left": (None, "left"), + "col_right": (None, "right"), + "description_bloc": (None, "description"), + "contact": (None, "contacts"), + "next_steps": (None, "message"), + } + + titre_bloc, field = field_map.get(zone_id, (None, "contenu")) + titre_couleur = self._r(zone.get("titre_couleur", + self.theme["colors"]["primary"]["dark_blue"])) + font_override = zone.get("font_override", {}) + + cur_top = top + + # Titre de bloc + if titre_bloc: + add_text_box(slide, left, cur_top, width, 0.50, + titre_bloc, self._font_body, 13, + bold=True, color=titre_couleur) + cur_top += 0.55 + + # Contenu + raw = slide_data.get(field, "") + if isinstance(raw, dict): + titre_col = raw.get("titre", "") + contenu = raw.get("contenu", "") + if titre_col: + add_text_box(slide, left, cur_top, width, 0.90, + titre_col, self._font_body, 26, + bold=True, color=titre_couleur) + cur_top += 1.40 + raw = contenu + + if raw: + color = font_override.get("color", self.theme["colors"]["text"]["body"]) + size_pt = font_override.get("size_pt", 16) + add_text_box(slide, left, cur_top, width, + height - (cur_top - top), + str(raw), self._font_body, size_pt, + color=color) + + # C08 — quote_block - + + def _render_quote_block(self, slide, zone, slide_data, + left, top, width, height): + """ + Layout key_message (L08). + Titre + message dans une seule textbox pour proximite native. + Fond beige via slide.background (deja pose par _render_background). + """ + from pptx.enum.text import MSO_ANCHOR + from pptx.util import Pt as _Pt + + SLIDE_W = 33.87 + SLIDE_H = 19.05 + FOOTER_TOP = 18.35 + margin = 8.00 + + titre = slide_data.get("titre", "") + message = slide_data.get("message", "") or slide_data.get("citation", "") + auteur = slide_data.get("auteur", "") + + if not titre and not message: + return + + box_w = SLIDE_W - 2 * margin + + # Taille titre + n = len(titre) + if n < 50: titre_size = 40 + elif n < 90: titre_size = 32 + else: titre_size = 26 + + # Taille message : 65% du titre, min 16pt + msg_size = max(int(titre_size * 0.65), 16) + + # Guillemets : coin bas-droit touche coin haut-gauche du bloc + # On estime le debut du bloc a ~35% de la hauteur utile + zone_h = FOOTER_TOP - 0.80 - 1.50 + bloc_est = 1.50 + zone_h * 0.20 # estimation bloc_top + g_size = titre_size + 36 + g_w = g_size * 0.025 + 0.80 + g_h = g_size * 0.038 + 0.20 + g_left = max(0.30, margin - g_w) + g_top = max(0.50, bloc_est - g_h * 0.70) + + add_text_box(slide, g_left, g_top, g_w + 0.50, g_h, + "\u201C", self._font_display, g_size, + bold=False, + color=self.theme["colors"]["primary"]["bright_blue"]) + + # Une seule textbox : titre + message (proximite native python-pptx) + # Hauteur = 60% de la zone utile pour centrage approximatif + box_h = zone_h * 0.60 + box_top = 1.50 + max(0, (zone_h - box_h) / 2) + + txBox = slide.shapes.add_textbox( + cm(margin), cm(box_top), + cm(box_w), cm(box_h)) + tf = txBox.text_frame + tf.word_wrap = True + tf.auto_size = None + tf.vertical_anchor = MSO_ANCHOR.MIDDLE + + # Paragraphe titre + p1 = tf.paragraphs[0] + p1.alignment = PP_ALIGN.LEFT + p1.space_before = _Pt(0) + p1.space_after = _Pt(4) + r1 = p1.add_run() + r1.text = titre + r1.font.name = self._font_display + r1.font.size = _Pt(titre_size) + r1.font.bold = True + r1.font.color.rgb = hex_to_rgb( + self.theme["colors"]["primary"]["dark_blue"]) + + # Paragraphe message (dans la meme textbox) + if message: + p2 = tf.add_paragraph() + p2.alignment = PP_ALIGN.LEFT + p2.space_before = _Pt(6) + p2.space_after = _Pt(0) + r2 = p2.add_run() + r2.text = message + r2.font.name = self._font_display + r2.font.size = _Pt(msg_size) + r2.font.bold = False + r2.font.color.rgb = hex_to_rgb( + self.theme["colors"]["text"]["body"]) + + # Attribution + if auteur: + attr_top = box_top + box_h + 0.40 + if attr_top + 0.60 < FOOTER_TOP - 0.50: + add_text_box(slide, margin, attr_top, box_w, 0.60, + auteur, self._font_body, 10, + color=self.theme["colors"]["text"]["caption"]) + + def _render_kpi_grid(self, slide, zone, slide_data, + left, top, width, height): + """Grille de cartes KPI adaptative.""" + items = slide_data.get("items", []) + if not items: + return + + n = len(items) + grid = {2: (2, 1), 3: (3, 1), 4: (2, 2), 5: (3, 2), 6: (3, 2)} + cols, rows = grid.get(n, (3, 2)) + gap = 0.40 + header_h = 0.80 + + card_w = (width - (cols - 1) * gap) / cols + card_h = (height - (rows - 1) * gap) / rows + + # Centrage vertical absolu (zone utile = bas titre slide -> haut footer) + FOOTER_TOP = 18.35 + TITLE_BOTTOM = top # top = cursor_y = bas du titre de la slide + zone_h_abs = FOOTER_TOP - 0.30 - TITLE_BOTTOM + + # Hauteur reelle des cartes : on redimensionne pour laisser de l'espace + card_h_real = min(card_h, zone_h_abs / rows - gap) + grid_h = rows * card_h_real + (rows - 1) * gap + + def row_top_offset(row): + if rows == 1: + # Centrer la ligne unique dans la zone utile + return TITLE_BOTTOM + max(0, (zone_h_abs - grid_h) / 2) + else: + # Diviser la zone en 2, centrer chaque ligne dans sa moitie + half_h = zone_h_abs / 2 + if row == 0: + return TITLE_BOTTOM + max(0, (half_h - card_h_real) / 2) + else: + return TITLE_BOTTOM + half_h + max(0, (half_h - card_h_real) / 2) + + card_h = card_h_real + + # Centrage horizontal de la grille dans la largeur disponible + grid_w = cols * card_w + (cols - 1) * gap + h_offset = max(0, (width - grid_w) / 2) + + for i, item in enumerate(items): + col = i % cols + row = i // cols + x = left + h_offset + col * (card_w + gap) + y = row_top_offset(row) + color = item.get("couleur") or self._cycle_color() + color = self._r(color) + # Header colore + add_rect(slide, x, y, card_w, header_h, color) + # Titre centre V+H via textbox MSO_ANCHOR.MIDDLE + from pptx.enum.text import MSO_ANCHOR as _MSO_KPI + txKpi = slide.shapes.add_textbox( + cm(x), cm(y), cm(card_w), cm(header_h)) + tfKpi = txKpi.text_frame + tfKpi.word_wrap = True + tfKpi.auto_size = None + tfKpi.vertical_anchor = _MSO_KPI.MIDDLE + pKpi = tfKpi.paragraphs[0] + pKpi.alignment = PP_ALIGN.CENTER + rKpi = pKpi.add_run() + rKpi.text = item.get("titre", "") + rKpi.font.name = self._font_body + rKpi.font.size = Pt(16) + rKpi.font.bold = True + rKpi.font.color.rgb = hex_to_rgb("#ffffff") + + # Body beige + add_rect(slide, x, y + header_h, card_w, + card_h - header_h, + self.theme["colors"]["backgrounds"]["content_area"]) + + # Valeur en gros + val_h = card_h - header_h - 1.20 + add_text_box(slide, x + 0.2, y + header_h + 0.3, + card_w - 0.4, val_h, + item.get("valeur", ""), + self._font_display, 32, + bold=True, + color=self.theme["colors"]["primary"]["rose"], + align=PP_ALIGN.CENTER) + + # Sous-titre + if item.get("sous_titre"): + add_text_box(slide, x + 0.2, + y + card_h - 1.10, + card_w - 0.4, 1.00, + item["sous_titre"], + self._font_body, 13, + color=self.theme["colors"]["text"]["body"]) + + # C10 — big_stat_display - + + def _render_big_stat(self, slide, zone, slide_data, + left, top, width, height): + """Chiffre unique centré en très grand format.""" + valeur = slide_data.get("valeur", "") + label = slide_data.get("label", "") + source = slide_data.get("source", "") + + # Taille dynamique selon longueur de la valeur + val_len = len(str(valeur)) + if val_len <= 4: + val_size = 144 + elif val_len <= 7: + val_size = 120 + else: + val_size = 96 + + val_h = val_size * 0.038 + center_top = top + (height - val_h - 1.5) / 2 + + # Centrage vertical du bloc stat + message + label_h = 1.40 if label else 0 + gap = 0.70 if label else 0 + bloc_h = val_h + gap + label_h + center_top = top + (height - bloc_h) / 2 + + add_text_box(slide, left, center_top, width, val_h + 0.3, + valeur, self._font_display, val_size, + bold=True, + color=self.theme["colors"]["primary"]["rose"], + align=PP_ALIGN.CENTER) + + if label: + label_margin = 3.00 + label_left = left + label_margin + label_w = width - 2 * label_margin + label_top = center_top + val_h + gap + add_text_box(slide, label_left, label_top, label_w, label_h, + label, self._font_body, 20, + color=self.theme["colors"]["text"]["body"], + align=PP_ALIGN.CENTER) + if source: + src_top = center_top + val_h + 1.30 + add_text_box(slide, left, src_top, width, 0.50, + "Source : " + source, self._font_body, 9, + color=self.theme["colors"]["text"]["caption"], + align=PP_ALIGN.CENTER) + + # C11 — data_table - + + def _render_data_table(self, slide, zone, slide_data, + left, top, width, height): + """Tableau structuré avec header bleu foncé et lignes alternées.""" + headers = slide_data.get("headers", []) + rows = slide_data.get("rows", []) + if not headers: + return + + highlight_col = slide_data.get("highlight_col") + col_widths_pct = slide_data.get("col_widths") + + n_cols = len(headers) + header_h = 0.65 + available_h = height - header_h + row_h = min(available_h / max(len(rows), 1), 0.80) + + # Largeurs de colonnes + if col_widths_pct: + col_widths = [w * width for w in col_widths_pct] + else: + col_widths = [width / n_cols] * n_cols + + # Header + x = left + for j, h in enumerate(headers): + add_rect(slide, x, top, col_widths[j], header_h, + self.theme["colors"]["primary"]["dark_blue"]) + add_text_box(slide, x + 0.15, top + 0.10, + col_widths[j] - 0.3, header_h - 0.15, + str(h), self._font_body, 9, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + x += col_widths[j] + + # Lignes + odd_bg = "#ffffff" + even_bg = self.theme["colors"]["backgrounds"]["content_area"] + highlight_bg = self.theme["colors"]["backgrounds"]["highlight_box"] + + for i, row in enumerate(rows): + y = top + header_h + i * row_h + x = left + for j, cell in enumerate(row): + bg = highlight_bg if j == highlight_col else ( + odd_bg if i % 2 == 0 else even_bg) + add_rect(slide, x, y, col_widths[j], row_h, bg) + add_text_box(slide, x + 0.15, y + 0.08, + col_widths[j] - 0.3, row_h - 0.10, + str(cell), self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + x += col_widths[j] + # Ligne séparatrice + add_line(slide, left, y + row_h, left + width, y + row_h, + "#e8e2d6", 0.02) + + # C12 — chart_placeholder - + + def _render_chart_placeholder(self, slide, zone, slide_data, + left, top, width, height): + """ + Graphique simplifié (bar chart) généré avec python-pptx Chart. + Pour un rendu avancé, remplacer par openpyxl + pptx chart data. + """ + from pptx.chart.data import ChartData + from pptx.enum.chart import XL_CHART_TYPE + + data_items = slide_data.get("data", []) + chart_type = slide_data.get("chart_type", "bar") + if not data_items: + self._render_placeholder(slide, left, top, width, height, "C12 chart") + return + + chart_data = ChartData() + chart_data.categories = [str(d.get("label", f"Item {i+1}")) + for i, d in enumerate(data_items)] + chart_data.add_series("", [float(d.get("valeur", 0)) + for d in data_items]) + + xl_type = { + "bar": XL_CHART_TYPE.BAR_CLUSTERED, + "line": XL_CHART_TYPE.LINE, + "pie": XL_CHART_TYPE.PIE, + "donut": XL_CHART_TYPE.DOUGHNUT, + }.get(chart_type, XL_CHART_TYPE.BAR_CLUSTERED) + + chart = slide.shapes.add_chart( + xl_type, + cm(left), cm(top), cm(width), cm(height), + chart_data + ).chart + + # Supprimer le titre du chart (on a déjà le titre du slide) + chart.has_title = False + chart.has_legend = False + + # Couleur des barres + series = chart.series[0] + fill = series.format.fill + fill.solid() + fill.fore_color.rgb = hex_to_rgb( + self.theme["colors"]["primary"]["bright_blue"]) + + # C13 — callout_box - + + def _render_callout_box(self, slide, zone, slide_data, + left, top, width, height): + """Encadré d'insight jaune.""" + insight = slide_data.get("insight", "") + titre = slide_data.get("titre_insight", "") + + # Fond + add_rect(slide, left, top, width, height, + self.theme["colors"]["backgrounds"]["highlight_box"], + self.theme["colors"]["secondary"]["maize_yellow"], 0.05) + + cur_top = top + 0.30 + if titre: + add_text_box(slide, left + 0.30, cur_top, + width - 0.60, 0.50, + titre, self._font_body, 12, + bold=True, + color=self.theme["colors"]["primary"]["rose"]) + cur_top += 0.55 + + if insight: + add_text_box(slide, left + 0.30, cur_top, + width - 0.60, height - (cur_top - top) - 0.30, + insight, self._font_body, 10, + color=self.theme["colors"]["text"]["body"]) + + # C14 — benchmark_bar - + + def _render_benchmark(self, slide, zone, slide_data, + left, top, width, height): + """Barres horizontales de benchmark.""" + criteria = slide_data.get("criteria", []) + actors = slide_data.get("actors", []) + scores = slide_data.get("scores", []) + if not criteria or not actors: + return + + colors_actors = slide_data.get("couleurs_acteurs") or [ + self.theme["colors"]["primary"]["bright_blue"], + self.theme["colors"]["primary"]["rose"], + self.theme["colors"]["secondary"]["barley_green"], + self.theme["colors"]["secondary"]["maize_yellow"], + ] + + n_crit = len(criteria) + n_act = len(actors) + label_w = 5.00 + bar_area_w = width - label_w + row_h = height / n_crit + bar_h = 0.30 + bar_gap = 0.10 + + # Légende acteurs (en haut) + for j, actor in enumerate(actors): + add_rect(slide, left + label_w + j * 2.0, top - 0.50, + 0.25, 0.25, colors_actors[j % len(colors_actors)]) + add_text_box(slide, left + label_w + j * 2.0 + 0.30, + top - 0.55, 1.5, 0.35, + actor, self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + for i, crit in enumerate(criteria): + y = top + i * row_h + + # Label critère + add_text_box(slide, left, y + row_h / 2 - 0.20, + label_w - 0.30, 0.40, + crit, self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # Barres par acteur + for j in range(n_act): + score = 0 + if i < len(scores) and j < len(scores[i]): + score = float(scores[i][j]) + bar_w = (score / 100) * bar_area_w + + bar_y = y + (row_h - n_act * (bar_h + bar_gap)) / 2 + j * (bar_h + bar_gap) + add_rect(slide, left + label_w, bar_y, + max(bar_w, 0.05), bar_h, + colors_actors[j % len(colors_actors)]) + + # C15 — matrix_bubble - + + def _render_matrix_2x2(self, slide, zone, slide_data, + left, top, width, height): + """Matrice 2×2 avec bulles positionnées.""" + axis_x = slide_data.get("axis_x", {}) + axis_y = slide_data.get("axis_y", {}) + items = slide_data.get("items", []) + + ax_label = str(axis_x.get("label", "")) + ay_label = str(axis_y.get("label", "")) + + # Marges pour les labels d'axes + margin_left = 1.50 + margin_bottom = 0.80 + plot_w = width - margin_left + plot_h = height - margin_bottom + + # Axes + add_line(slide, left + margin_left, top, + left + margin_left, top + plot_h, + "#000a32", 0.05) + add_line(slide, left + margin_left, top + plot_h, + left + width, top + plot_h, + "#000a32", 0.05) + + # Lignes de quadrant + mid_x = left + margin_left + plot_w / 2 + mid_y = top + plot_h / 2 + add_line(slide, mid_x, top, mid_x, top + plot_h, "#48545a", 0.02) + add_line(slide, left + margin_left, mid_y, + left + width, mid_y, "#48545a", 0.02) + + # Labels axes + add_text_box(slide, left + margin_left + plot_w / 2 - 2, + top + plot_h + 0.10, + 4, 0.40, ax_label, + self._font_body, 9, color="#48545a", + align=PP_ALIGN.CENTER) + add_text_box(slide, left, top + plot_h / 2 - 0.30, + margin_left - 0.10, 0.60, ay_label, + self._font_body, 9, color="#48545a", + align=PP_ALIGN.RIGHT) + + # Labels extremes + add_text_box(slide, left + margin_left - 0.5, top + plot_h - 0.20, + 0.8, 0.30, "Low", self._font_body, 8, color="#48545a") + add_text_box(slide, left + margin_left - 0.5, top, + 0.8, 0.30, "High", self._font_body, 8, color="#48545a") + add_text_box(slide, left + margin_left, top + plot_h, + 0.8, 0.30, "Low", self._font_body, 8, color="#48545a") + add_text_box(slide, left + width - 1.0, top + plot_h, + 1.0, 0.30, "High", self._font_body, 8, color="#48545a", + align=PP_ALIGN.RIGHT) + + colors = self.theme["colors"]["cycle"] + for i, item in enumerate(items): + x_pct = item.get("x", 50) / 100 + y_pct = 1 - item.get("y", 50) / 100 # inverser y (0 = bas) + size_factor = item.get("taille", 2) + diameter = 0.30 + (size_factor - 1) * 0.15 + color = self._r(item.get("couleur") or colors[i % len(colors)]) + + bx = left + margin_left + x_pct * plot_w - diameter / 2 + by = top + y_pct * plot_h - diameter / 2 + + shape = slide.shapes.add_shape(9, # ellipse + cm(bx), cm(by), cm(diameter), cm(diameter)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(color) + shape.fill.fore_color.theme_color + shape.line.fill.background() + # Opacité via XML + spPr = shape._element.spPr + solidFill = spPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}solidFill') + if solidFill is not None: + srgbClr = solidFill.find('{http://schemas.openxmlformats.org/drawingml/2006/main}srgbClr') + if srgbClr is not None: + alpha = etree.SubElement(srgbClr, + '{http://schemas.openxmlformats.org/drawingml/2006/main}alpha') + alpha.set('val', '75000') # 75% opacité + + # Label + add_text_box(slide, bx - 0.5, by + diameter + 0.05, + diameter + 1.0, 0.35, + item.get("label", ""), + self._font_body, 8, + color=self.theme["colors"]["primary"]["dark_blue"], + align=PP_ALIGN.CENTER) + + # C16 — pyramid_level - + + def _render_pyramid(self, slide, zone, slide_data, + left, top, width, height): + """Pyramide hiérarchique.""" + levels = slide_data.get("levels", []) + if not levels: + return + + n = len(levels) + colors_default = [ + "#7fa5d0", "#000a32", "#bad9ff", "#ffcf0f", "#d9d9c4" + ] + level_h = height / n + center_x = left + width / 2 + max_w = width * 0.55 + callout_right_x = left + width * 0.70 + + for i, level in enumerate(levels): + rank = i + 1 + frac = rank / n + lvl_w = max_w * frac + lvl_left = center_x - lvl_w / 2 + lvl_top = top + i * level_h + color = self._r(level.get("couleur") or colors_default[i % len(colors_default)]) + + add_rect(slide, lvl_left, lvl_top, lvl_w, level_h - 0.05, color) + add_text_box(slide, lvl_left, lvl_top + level_h / 2 - 0.20, + lvl_w, 0.40, + level.get("label", ""), + self._font_body, 9, + bold=True, + color="#ffffff" if i in [1] else "#000a32", + align=PP_ALIGN.CENTER) + + # Callout latéral + if level.get("description"): + side = "right" if i % 2 == 0 else "left" + if side == "right": + add_line(slide, lvl_left + lvl_w, lvl_top + level_h / 2, + callout_right_x, lvl_top + level_h / 2, + "#48545a", 0.02) + add_text_box(slide, callout_right_x + 0.10, + lvl_top + level_h / 2 - 0.20, + left + width - callout_right_x - 0.20, + 0.60, level["description"], + self._font_body, 8, + color=self.theme["colors"]["text"]["body"]) + else: + callout_left_x = left + add_line(slide, lvl_left, lvl_top + level_h / 2, + callout_left_x + width * 0.25, + lvl_top + level_h / 2, "#48545a", 0.02) + add_text_box(slide, callout_left_x, + lvl_top + level_h / 2 - 0.20, + width * 0.24, 0.60, + level["description"], + self._font_body, 8, + color=self.theme["colors"]["text"]["body"], + align=PP_ALIGN.RIGHT) + + # C17 — circular_segment - + + def _render_circular_diagram_full(self, slide, slide_data: dict, + layout_cfg: dict, title_bottom: float): + """ + Wrapper unique pour circular_diagram. + Appelle _render_circular_diagram une seule fois avec la zone complete. + Evite le double rendu du aux zones circle+legend dans content_zones. + """ + zones = layout_cfg.get("content_zones", []) if layout_cfg else [] + # Prendre la zone circle comme zone principale + zone = next((z for z in zones if z.get("id") == "circle"), {}) + + FOOTER_TOP = 18.35 + left = zone.get("left_cm", 1.50) + top = max(title_bottom + 0.30, zone.get("top_cm", 2.00)) + width = zone.get("width_cm", 30.87) + height = min(zone.get("height_cm", 14.85), FOOTER_TOP - 0.30 - top) + + self._render_circular_diagram(slide, zone, slide_data, + left, top, width, height) + + def _render_circular_diagram(self, slide, zone, slide_data, + left, top, width, height): + """ + Diagramme circulaire forme fleur. + Grands cercles positiones en arc autour d un centre commun. + Chaque cercle contient son numero en haut gauche. + Legende a droite : pastille + titre bold + description. + """ + segments = slide_data.get("segments", []) + if not segments: + return + + colors_default = self.theme["colors"]["cycle"] + n = len(segments) + + FOOTER_TOP = 18.35 + # Zone utile + usable_h = min(height, FOOTER_TOP - 0.30 - top) + + # Dimensions de la zone diagramme (gauche 55%) et legende (droite 45%) + diag_w = width * 0.52 + leg_x = left + diag_w + 0.50 + + # Centre du diagramme fleur + cx = left + diag_w * 0.50 + cy = top + usable_h * 0.50 + + # Rayon des grands cercles et rayon d orbite + r_cercle = min(diag_w * 0.28, usable_h * 0.28) * 0.67 # reduit d 1/3 + r_orbite = r_cercle * 0.85 # chevauchement : les cercles se touchent au centre + + # Cercle central blanc (medaillon) + r_med = r_cercle * 0.40 + shape_med = slide.shapes.add_shape(9, + cm(cx - r_med), cm(cy - r_med), + cm(r_med * 2), cm(r_med * 2)) + shape_med.fill.solid() + shape_med.fill.fore_color.rgb = hex_to_rgb("#ffffff") + shape_med.line.fill.background() + shape_med.line.fill.background() + + # Angles de depart selon le nombre de segments + # On commence en haut gauche pour 3 segments, en haut pour 4 et 5 + angle_offsets = { + 2: -90, + 3: -150, + 4: -135, + 5: -90, + 6: -90, + } + angle_start = angle_offsets.get(n, -90) + angle_step = 360 / n + + for i, seg in enumerate(segments): + color = self._r(seg.get("couleur") or colors_default[i % len(colors_default)]) + angle_rad = math.radians(angle_start + i * angle_step) + + # Centre du cercle colore + bx = cx + r_orbite * math.cos(angle_rad) + by = cy + r_orbite * math.sin(angle_rad) + + # Grand cercle colore + shape = slide.shapes.add_shape(9, + cm(bx - r_cercle), cm(by - r_cercle), + cm(r_cercle * 2), cm(r_cercle * 2)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(color) + shape.line.fill.background() + + # Numero dans le cercle - positionnement selon quadrant + num_size = max(14, int(r_cercle * 8)) + num_w = r_cercle * 1.40 + num_h = r_cercle * 0.80 + marge = r_cercle * 0.15 + sin_a = math.sin(angle_rad) + cos_a = math.cos(angle_rad) + + if sin_a < -0.3: + # Petale au-dessus de l horizontale -> numero en haut du rond + num_x = bx - num_w / 2 + num_y = by - r_cercle + marge + elif sin_a > 0.3: + # Petale en dessous de l horizontale -> numero en bas du rond + num_x = bx - num_w / 2 + num_y = by + r_cercle - marge - num_h + elif cos_a > 0: + # Petale exactement sur l horizontale, cote droit -> num a droite + num_x = bx + r_cercle - marge - num_w + num_y = by - num_h / 2 + else: + # Petale exactement sur l horizontale, cote gauche -> num a gauche + num_x = bx - r_cercle + marge + num_y = by - num_h / 2 + + _tb_num = add_text_box(slide, num_x, num_y, + num_w, num_h, + f"0{i+1}", + self._font_display, num_size, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + from pptx.util import Pt as _Pt + from pptx.enum.text import MSO_ANCHOR + _tb_num.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE + + # Redessiner le medaillon central par dessus les cercles + shape_med2 = slide.shapes.add_shape(9, + cm(cx - r_med), cm(cy - r_med), + cm(r_med * 2), cm(r_med * 2)) + shape_med2.fill.solid() + shape_med2.fill.fore_color.rgb = hex_to_rgb("#ffffff") + shape_med2.line.fill.background() + + # Legende a droite + leg_item_h = min(1.60, usable_h / n) + leg_total = n * leg_item_h + leg_top = top + (usable_h - leg_total) / 2 + leg_w = left + width - leg_x - 0.20 + + for i, seg in enumerate(segments): + color = self._r(seg.get("couleur") or colors_default[i % len(colors_default)]) + y = leg_top + i * leg_item_h + + # Pastille coloree + p_r = 0.30 + shape_p = slide.shapes.add_shape(9, + cm(leg_x), cm(y + 0.15), + cm(p_r * 2), cm(p_r * 2)) + shape_p.fill.solid() + shape_p.fill.fore_color.rgb = hex_to_rgb(color) + shape_p.line.fill.background() + + # Numero + label bold + add_text_box(slide, leg_x + p_r * 2 + 0.20, y, + leg_w - p_r * 2 - 0.20, 0.55, + f"0{i+1} {seg.get('label', '')}", + self._font_body, 13, + bold=True, + color=self.theme["colors"]["primary"]["dark_blue"]) + + # Description + if seg.get("description"): + add_text_box(slide, leg_x + p_r * 2 + 0.20, y + 0.55, + leg_w - p_r * 2 - 0.20, 0.85, + seg["description"], + self._font_body, 11, + color=self.theme["colors"]["text"]["body"]) + + # C18 — from_to_pair - + + def _render_from_to_pairs(self, slide, zone, slide_data, + left, top, width, height): + """Paires FROM → TO.""" + pairs = slide_data.get("pairs", []) + if not pairs: + return + + # En-tête FROM / TO + mid_x = left + width * 0.42 + add_text_box(slide, left, top, width * 0.40, 0.50, + "FROM", self._font_body, 11, + bold=True, + color=self.theme["semantic"]["arrow_color"]) + add_text_box(slide, mid_x + 0.80, top, width * 0.40, 0.50, + "TO", self._font_body, 11, + bold=True, + color=self.theme["semantic"]["arrow_color"]) + + row_h = (height - 0.60) / max(len(pairs), 1) + for i, pair in enumerate(pairs): + y = top + 0.60 + i * row_h + # FROM (atténué) + add_text_box(slide, left, y + 0.08, + width * 0.38, row_h - 0.15, + pair.get("from", ""), + self._font_body, 11, + color=self.theme["semantic"]["from_color"]) + + # Flèche + add_text_box(slide, mid_x - 0.20, y + 0.05, 0.60, 0.40, + "›", self._font_body, 18, bold=True, + color=self.theme["semantic"]["arrow_color"], + align=PP_ALIGN.CENTER) + + # TO (affirmé) + add_text_box(slide, mid_x + 0.50, y + 0.08, + width - mid_x - 0.50, row_h - 0.15, + pair.get("to", ""), + self._font_body, 11, + bold=True, + color=self.theme["semantic"]["to_color"]) + + # Séparateur + if i < len(pairs) - 1: + add_line(slide, left, y + row_h, + left + width, y + row_h, + "#e8e2d6", 0.02) + + # C19 — step_item - + + def _render_numbered_steps(self, slide, zone, slide_data, + left, top, width, height): + """Étapes numérotées verticalement.""" + steps = slide_data.get("steps", []) + if not steps: + return + + row_h = height / max(len(steps), 1) + badge_size = 1.40 + + for i, step in enumerate(steps): + y = top + i * row_h + + # Badge carré + add_rect(slide, left, y + (row_h - badge_size) / 2, + badge_size, badge_size, + self.theme["colors"]["primary"]["dark_blue"]) + add_text_box(slide, left, y + (row_h - badge_size) / 2, + badge_size, badge_size, + str(step.get("numero", i + 1)), + self._font_body, 30, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + + # Titre + add_text_box(slide, left + badge_size + 0.25, + y + (row_h - badge_size) / 2, + width * 0.35, badge_size, + step.get("titre", ""), + self._font_body, 17, + bold=True, + color=self.theme["colors"]["primary"]["dark_blue"]) + + # Description + if step.get("description"): + add_text_box(slide, left + badge_size + 0.25 + width * 0.36, + y + (row_h - badge_size) / 2, + width - badge_size - 0.25 - width * 0.36, + badge_size, + step["description"], + self._font_body, 13, + color=self.theme["colors"]["text"]["body"]) + + # Séparateur + if i < len(steps) - 1: + add_line(slide, left, y + row_h, + left + width, y + row_h, + "#e8e2d6", 0.02) + + # C20 — chevron_step - + + def _render_chevrons(self, slide, zone, slide_data, + left, top, width, height): + """Chevrons horizontaux de process.""" + phases = slide_data.get("phases", []) + if not phases: + return + + n = len(phases) + tip = 0.40 # largeur de la pointe + chev_h = 1.00 + total_w = width - 0.50 + chev_w = total_w / n + bullets_top = top + chev_h + 0.30 + + for i, phase in enumerate(phases): + x = left + i * chev_w + is_active = phase.get("actif", False) + is_last = (i == n - 1) + + fill = (self.theme["colors"]["primary"]["dark_blue"] + if is_active else + self.theme["colors"]["primary"]["hague_grey"]) + + # Rectangle du chevron + add_rect(slide, x, top, chev_w - 0.10, chev_h, fill) + # Texte + add_text_box(slide, x + 0.20, top + 0.20, + chev_w - 0.60, 0.60, + phase.get("label", ""), + self._font_display, 13, + bold=True, + color=self.theme["colors"]["primary"]["rose"] + if is_active else "#ffffff", + align=PP_ALIGN.CENTER) + + # Durée sous le chevron + if phase.get("duree"): + add_text_box(slide, x, top + chev_h + 0.05, + chev_w, 0.30, + phase["duree"], + self._font_body, 8, + color=self.theme["colors"]["text"]["body"], + align=PP_ALIGN.CENTER) + + # Bullets sous la phase + if phase.get("bullets"): + for j, bullet in enumerate(phase["bullets"]): + add_text_box(slide, x + 0.15, + bullets_top + j * 0.55, + chev_w - 0.30, 0.50, + "• " + bullet, + self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # C21 — gantt_bar - + + def _render_gantt(self, slide, zone, slide_data, + left, top, width, height): + """Gantt simplifié.""" + period = slide_data.get("period", {}) + workstreams = slide_data.get("workstreams", []) + if not workstreams: + return + + label_w = zone.get("label_col_width_cm", 5.50) + header_h = zone.get("header_height_cm", 0.60) + stream_h = zone.get("workstream_height_cm", 2.80) + timeline_w = width - label_w + + # Parse période + def parse_ym(s): + parts = str(s).split("-") + return int(parts[0]) * 12 + int(parts[1]) if len(parts) == 2 else 0 + + p_start = parse_ym(period.get("start", "2026-01")) + p_end = parse_ym(period.get("end", "2026-12")) + total_months = max(p_end - p_start + 1, 1) + + # Header mois + import calendar + months_short = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + add_rect(slide, left + label_w, top, timeline_w, header_h, + self.theme["colors"]["backgrounds"]["content_area"]) + + for m in range(total_months): + mx = left + label_w + (m / total_months) * timeline_w + mw = timeline_w / total_months + ym = p_start + m + month_name = months_short[(ym - 1) % 12] + add_text_box(slide, mx, top + 0.08, mw, 0.40, + month_name, self._font_body, 7, + color="#48545a", align=PP_ALIGN.CENTER) + + colors = self.theme["colors"]["cycle"] + + for i, ws in enumerate(workstreams): + y = top + header_h + i * stream_h + color = colors[i % len(colors)] + + # Label workstream (optionnel) + if ws.get("label"): + add_rect(slide, left, y, label_w - 0.20, stream_h - 0.10, + self.theme["colors"]["backgrounds"]["content_area"]) + add_text_box(slide, left + 0.15, y + stream_h / 2 - 0.20, + label_w - 0.40, 0.40, + ws["label"], self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + else: + add_rect(slide, left, y, label_w - 0.20, stream_h - 0.10, + "#e8e2d6") + + # Barres de tâches + for task in ws.get("tasks", []): + t_start = parse_ym(task.get("start", period.get("start"))) + t_end = parse_ym(task.get("end", period.get("end"))) + row = task.get("row", 1) + + offset_x = ((t_start - p_start) / total_months) * timeline_w + bar_w = max(((t_end - t_start + 1) / total_months) * timeline_w, 0.30) + bar_y = y + (row - 1) * (stream_h / 2) + 0.25 + bar_h = stream_h / 2 - 0.35 + + tc = self._r(task.get("couleur") or color) + add_rect(slide, left + label_w + offset_x, bar_y, + bar_w, bar_h, tc) + + # C22 — timeline_milestone - + + def _render_timeline(self, slide, zone, slide_data, + left, top, width, height): + """Timeline horizontale (yearly ou phases).""" + milestones = slide_data.get("milestones", []) + if not milestones: + # phases_timeline variant + self._render_phases_timeline(slide, zone, slide_data, left, top, width, height) + return + + n = len(milestones) + axis_y = top + height / 2 + spacing = width / (n + 1) + + # Axe + add_line(slide, left, axis_y, left + width, axis_y, "#48545a", 0.04) + # Flèche → + add_text_box(slide, left + width - 0.30, axis_y - 0.20, + 0.40, 0.40, "→", self._font_body, 10, color="#48545a") + + colors = self.theme["colors"] + for i, m in enumerate(milestones): + mx = left + (i + 1) * spacing + is_active = m.get("actif", False) + circle_color = (colors["primary"]["rose"] if is_active + else colors["primary"]["dark_blue"]) + year_color = (colors["primary"]["rose"] if is_active + else colors["primary"]["bright_blue"]) + + # Cercle sur l'axe + r = 0.18 + shape = slide.shapes.add_shape(9, + cm(mx - r), cm(axis_y - r), cm(r * 2), cm(r * 2)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(circle_color) + shape.line.fill.background() + + # Année au-dessus + add_text_box(slide, mx - 1.0, axis_y - 1.20, + 2.0, 0.50, str(m.get("annee", "")), + self._font_display, 13, + bold=True, color=year_color, + align=PP_ALIGN.CENTER) + + # Label + add_text_box(slide, mx - 1.5, axis_y + 0.30, + 3.0, 0.40, m.get("label", ""), + self._font_body, 9, + bold=True if is_active else False, + color=colors["primary"]["dark_blue"], + align=PP_ALIGN.CENTER) + + # Description + if m.get("description"): + add_text_box(slide, mx - 1.5, axis_y + 0.75, + 3.0, 0.70, m["description"], + self._font_body, 8, + color=colors["text"]["body"], + align=PP_ALIGN.CENTER) + + def _render_phases_timeline(self, slide, zone, slide_data, + left, top, width, height): + """Phases horizontales contiguës (L24).""" + phases = slide_data.get("phases", []) + if not phases: + return + + n = len(phases) + phase_h = 1.40 + period_h = 0.80 + colors = self.theme["colors"]["cycle"] + + # Largeur proportionnelle ou égale + phase_w = width / n + + # Calcul hauteur totale du contenu pour centrage vertical + max_items = max((len(ph.get("items", [])) for ph in phases), default=0) + total_h = phase_h + period_h + max_items * 0.70 + _voff = max(0.0, (height - total_h) / 2) + top_c = top + _voff + + from pptx.enum.text import MSO_ANCHOR + + for i, phase in enumerate(phases): + x = left + i * phase_w + color = colors[i % len(colors)] + + add_rect(slide, x, top_c, phase_w - 0.10, phase_h, color) + _tb = add_text_box(slide, x + 0.10, top_c, + phase_w - 0.20, phase_h, + phase.get("label", ""), + self._font_body, 16, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + _tb.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE + + if phase.get("periode"): + add_text_box(slide, x, top_c + phase_h + 0.05, + phase_w, period_h, + phase["periode"], + self._font_body, 14, + color=self._r(color), + align=PP_ALIGN.CENTER) + + items = phase.get("items", []) + for j, item in enumerate(items): + add_text_box(slide, x + 0.10, + top_c + phase_h + period_h + 0.20 + j * 0.70, + phase_w - 0.20, 0.65, + "• " + item, + self._font_body, 12, + color=self.theme["colors"]["text"]["body"]) + + # C23 — org_node - + + def _render_org_chart(self, slide, zone, slide_data, + left, top, width, height): + """Organigramme hiérarchique top-down.""" + root = slide_data.get("root", {}) + if not root: + return + + colors_by_level = [ + self.theme["colors"]["primary"]["dark_blue"], + self.theme["colors"]["secondary"]["barley_green"], + self.theme["colors"]["secondary"]["cool_blue"], + self.theme["colors"]["primary"]["bright_warm"], + ] + text_by_level = ["#ffffff", "#ffffff", "#000a32", "#000a32"] + + node_h = 0.65 + level_gap = 1.20 + + def draw_tree(node, level, x_center, y): + color = colors_by_level[min(level, len(colors_by_level) - 1)] + txt_color = text_by_level[min(level, len(text_by_level) - 1)] + node_w = max(3.0 - level * 0.3, 2.0) + + nx = x_center - node_w / 2 + add_rect(slide, nx, y, node_w, node_h, color) + add_text_box(slide, nx + 0.10, y + 0.12, + node_w - 0.20, node_h - 0.15, + node.get("label", ""), + self._font_body, 8, + bold=True, color=txt_color, + align=PP_ALIGN.CENTER) + + children = node.get("children", []) + if not children: + return + + nc = len(children) + child_span = min(width / max(nc, 1), 6.0) + children_total_w = child_span * nc + child_start_x = x_center - children_total_w / 2 + child_span / 2 + + child_y = y + node_h + level_gap + + # Ligne verticale descendante + add_line(slide, x_center, y + node_h, + x_center, y + node_h + level_gap / 2, + "#48545a", 0.03) + + # Ligne horizontale + add_line(slide, + child_start_x, y + node_h + level_gap / 2, + child_start_x + children_total_w - child_span, + y + node_h + level_gap / 2, + "#48545a", 0.03) + + for i, child in enumerate(children): + cx = child_start_x + i * child_span + add_line(slide, cx, y + node_h + level_gap / 2, + cx, child_y, "#48545a", 0.03) + draw_tree(child, level + 1, cx, child_y) + + draw_tree(root, 0, left + width / 2, top) + + # C24 — raci_cell - + + def _render_raci(self, slide, zone, slide_data, + left, top, width, height): + """Matrice RACI.""" + roles = slide_data.get("roles", []) + tasks = slide_data.get("tasks", []) + if not roles or not tasks: + return + + task_col_w = zone.get("task_col_width_cm", 8.00) + header_h = 0.65 + role_col_w = (width - task_col_w) / max(len(roles), 1) + row_h = min((height - header_h) / max(len(tasks), 1), 0.80) + + raci_colors = { + "R": self.theme["semantic"]["responsible"], + "A": self.theme["semantic"]["accountable"], + "C": self.theme["semantic"]["consulted"], + "I": self.theme["semantic"]["informed"], + } + + # Header + add_rect(slide, left, top, task_col_w, header_h, + self.theme["colors"]["backgrounds"]["content_area"]) + for j, role in enumerate(roles): + x = left + task_col_w + j * role_col_w + add_rect(slide, x, top, role_col_w, header_h, + self.theme["colors"]["primary"]["dark_blue"]) + add_text_box(slide, x, top + 0.10, role_col_w, 0.45, + role, self._font_body, 9, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + + for i, task in enumerate(tasks): + y = top + header_h + i * row_h + bg = ("#ffffff" if i % 2 == 0 + else self.theme["colors"]["backgrounds"]["content_area"]) + + add_rect(slide, left, y, task_col_w, row_h, bg) + add_text_box(slide, left + 0.20, y + 0.12, + task_col_w - 0.30, row_h - 0.15, + task.get("label", ""), + self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + raci_vals = task.get("raci", []) + for j in range(len(roles)): + x = left + task_col_w + j * role_col_w + add_rect(slide, x, y, role_col_w, row_h, bg) + + val = raci_vals[j] if j < len(raci_vals) else "" + if val in raci_colors: + r_d = 0.35 + r_x = x + role_col_w / 2 - r_d / 2 + r_y = y + row_h / 2 - r_d / 2 + opacity = 1.0 if val in ("R", "A", "C") else 0.45 + shape = slide.shapes.add_shape(9, + cm(r_x), cm(r_y), cm(r_d), cm(r_d)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(raci_colors[val]) + shape.line.fill.background() + + add_text_box(slide, r_x, r_y + 0.04, + r_d, r_d - 0.08, + val, self._font_body, 8, + bold=True, + color="#ffffff" if val in ("R", "A") else "#000a32", + align=PP_ALIGN.CENTER) + + add_line(slide, left, y + row_h, left + width, y + row_h, "#e8e2d6", 0.02) + + # C25 — decision_node - + + def _render_decision_tree(self, slide, zone, slide_data, + left, top, width, height): + """Arbre de décision YES/NO.""" + question = slide_data.get("question", "") + branches = slide_data.get("branches", {}) + + # Question centrale + q_w, q_h = 7.0, 2.80 + q_x = left + 0.50 + q_y = top + height / 2 - q_h / 2 + + add_rect(slide, q_x, q_y, q_w, q_h, + self.theme["colors"]["backgrounds"]["content_area"], + "#48545a", 0.03) + add_text_box(slide, q_x + 0.30, q_y + 0.30, + q_w - 0.60, q_h - 0.60, + question, self._font_body, 10, + color=self.theme["colors"]["primary"]["dark_blue"]) + + branch_configs = [ + ("yes", "YES", top + height * 0.20), + ("no", "NO", top + height * 0.65), + ] + colors_branch = { + "yes": self.theme["colors"]["primary"]["bright_warm"], + "no": self.theme["colors"]["primary"]["rose"], + } + + for key, label, branch_y in branch_configs: + branch = branches.get(key, {}) + if not branch: + continue + + # Connecteur + label YES/NO + add_line(slide, q_x + q_w, q_y + q_h / 2, + left + q_w + 2.0, branch_y + 1.0, + "#48545a", 0.03) + add_text_box(slide, q_x + q_w + 0.20, + (q_y + q_h / 2 + branch_y + 1.0) / 2 - 0.15, + 0.80, 0.30, label, + self._font_body, 8, bold=True, + color="#48545a") + + # Nœud branche + b_x = left + q_w + 2.0 + b_w, b_h = 6.0, 2.20 + branch_color = colors_branch.get(key, "#d9d9c4") + add_rect(slide, b_x, branch_y, b_w, b_h, + branch_color, "#48545a", 0.03) + add_text_box(slide, b_x + 0.25, branch_y + 0.25, + b_w - 0.50, b_h - 0.50, + branch.get("label", ""), + self._font_body, 10, + color=self.theme["colors"]["primary"]["dark_blue"]) + + # Options terminales + options = branch.get("options", []) + opt_x = b_x + b_w + 0.80 + opt_w = left + width - opt_x - 0.20 + for k, opt in enumerate(options[:2]): + opt_y = branch_y + k * 1.20 + add_line(slide, b_x + b_w, branch_y + b_h / 2, + opt_x, opt_y + 0.40, "#48545a", 0.02) + add_rect(slide, opt_x, opt_y, opt_w, 1.0, + self.theme["colors"]["backgrounds"]["content_area"], + "#e8e2d6", 0.02) + add_text_box(slide, opt_x + 0.20, opt_y + 0.15, + opt_w - 0.40, 0.70, + opt, self._font_body, 9, + color=self.theme["colors"]["primary"]["dark_blue"]) + + # C26 — recommendation_sidebar - + + def _render_recommendation_sidebar(self, slide, zone, slide_data, + left, top, width, height): + """Sidebar jaune + corps de la recommandation.""" + numero = slide_data.get("numero", 1) + titre = slide_data.get("titre", "") + resume = slide_data.get("resume", "") + cta = slide_data.get("cta", "") + headline = slide_data.get("headline", "") + bullets = slide_data.get("bullets", []) + + # Sidebar fond lin + sidebar_w = width # width = 8.0 cm (défini dans layouts.yaml) + add_rect(slide, left, top, sidebar_w, height, "#E8DCC8") + + # --- Centrage vertical dynamique des 3 blocs --- + # Bloc 1 : cercle numéro (diamètre = r*2) + # Bloc 2 : titre (hauteur estimée ~1.50cm) + # Bloc 3 : subtitle (hauteur estimée ~1.0cm si présent) + from pptx.enum.text import MSO_ANCHOR + r = 1.25 + circle_h = r * 2 + titre_h = 1.80 + subtitle = slide_data.get("subtitle", resume) + sub_h = 1.0 if subtitle else 0.0 + gap = 0.40 + n_gaps = 2 if subtitle else 1 + total_h = circle_h + titre_h + sub_h + n_gaps * gap + _voff = max(0.0, (height - total_h) / 2) + + # Cercle numéro - centré horizontalement + cx = left + sidebar_w / 2 + circle_top = top + _voff + shape = slide.shapes.add_shape(9, + cm(cx - r), cm(circle_top), cm(r * 2), cm(r * 2)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb( + self.theme["colors"]["primary"]["dark_blue"]) + shape.line.fill.background() + _tb_num = add_text_box(slide, cx - r, circle_top, r * 2, r * 2, + str(numero), self._font_display, 40, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + _tb_num.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE + + # Titre sidebar - centré horizontalement + titre_top = circle_top + circle_h + gap + _tb_titre = add_text_box(slide, left + 0.30, titre_top, + sidebar_w - 0.60, titre_h, + titre, self._font_display, 24, + bold=True, + color=self.theme["colors"]["primary"]["dark_blue"], + align=PP_ALIGN.CENTER) + _tb_titre.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE + + # Subtitle (sous-topic) + if subtitle: + sub_top = titre_top + titre_h + gap + _tb_sub = add_text_box(slide, left + 0.30, sub_top, + sidebar_w - 0.60, sub_h, + subtitle, self._font_body, 12, + color=self.theme["colors"]["text"]["body"], + align=PP_ALIGN.CENTER) + _tb_sub.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE + + # CTA integre dans le centrage vertical du volet gauche + if cta: + # Hauteur CTA dynamique selon longueur du texte + cta_w = sidebar_w - 0.60 + cta_h = max(0.80, estimate_text_height(cta, 12, cta_w) + 0.30) + # CTA ancré directement sous le bloc titre (+ subtitle si présent) + cta_top = titre_top + titre_h + (gap + sub_h if subtitle else 0) + add_rect(slide, left + 0.30, cta_top, + cta_w, cta_h, + self.theme["colors"]["primary"]["dark_blue"]) + _tb_cta = add_text_box(slide, left + 0.30, cta_top, + cta_w, cta_h, + cta, self._font_body, 12, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + _tb_cta.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE + + # - Volet droit - + content_left = left + sidebar_w + 0.50 + content_w = 33.87 - content_left - 0.50 + marge_lat = 0.60 # marge gauche et droite identique dans le volet droit + + # Calcul hauteur totale volet droit pour centrage vertical + headline_h = 1.20 if headline else 0.0 + pad_v = 1.20 # padding haut et bas dans le bloc lin + _pt_per_lvl = {1: 24, 2: 20, 3: 17} + bullets_dedup = [] + seen = set() + for b in bullets: + key = b.get("texte", "") + if key not in seen: + seen.add(key) + bullets_dedup.append(b) + bullets_pt = sum(_pt_per_lvl.get(b.get("niveau", 1), 24) for b in bullets_dedup) + bullets_cm = bullets_pt * 0.03528 + lin_h = bullets_cm + pad_v * 2 + total_right_h = headline_h + lin_h + _voff_right = max(0.0, (height - total_right_h) / 2) + right_top = top + _voff_right + inner_w = content_w - marge_lat * 2 + LIN_COLOR = "#E8DCC8" # couleur lin authentique + + # Header band bleu + if headline: + add_rect(slide, content_left + marge_lat, right_top, + inner_w, headline_h, + self.theme["colors"]["primary"]["dark_blue"]) + _tb_hl = add_text_box(slide, + content_left + marge_lat + 0.30, right_top, + inner_w - 0.60, headline_h, + headline, self._font_body, 20, + bold=True, color="#ffffff") + _tb_hl.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE + + # Bloc lin avec bullets + if bullets_dedup: + lin_top = right_top + headline_h + add_rect(slide, content_left + marge_lat, lin_top, + inner_w, lin_h, + LIN_COLOR) + # Bullets avec padding interne + zone_fake = {"id": "main_content", "component": "C06", + "width_cm": inner_w - 0.60} + slide_fake = {"bullets": bullets_dedup} + # height = bullets_cm exact pour neutraliser le calcul space_before interne + self._render_bullet_list(slide, zone_fake, slide_fake, + content_left + marge_lat + 0.30, + lin_top + pad_v, + inner_w - 0.60, + bullets_cm, + no_bold=True) + + +# - +# CLI +# - + +def main(): + import argparse + parser = argparse.ArgumentParser( + description="Sliding render_engine — JSON → PPTX Pernod Ricard") + parser.add_argument("json_file", + help="Fichier JSON de la présentation (sortie Agent 3)") + parser.add_argument("output", + help="Chemin du fichier PPTX à générer") + parser.add_argument("--theme", + default="theme.yaml", + help="Chemin vers theme.yaml (défaut: ./theme.yaml)") + parser.add_argument("--components", + default="components.yaml", + help="Chemin vers components.yaml") + parser.add_argument("--layouts", + default="layouts.yaml", + help="Chemin vers layouts.yaml") + args = parser.parse_args() + + if not os.path.exists(args.json_file): + print(f"✗ Fichier JSON introuvable : {args.json_file}") + sys.exit(1) + for f in [args.theme, args.components, args.layouts]: + if not os.path.exists(f): + print(f"✗ Fichier YAML introuvable : {f}") + sys.exit(1) + + engine = RenderEngine(args.theme, args.components, args.layouts) + with open(args.json_file, encoding="utf-8") as f: + raw = f.read().strip() + if not raw: + print(f"\u2717 Fichier d\'entr\u00e9e vide : {args.json_file}") + sys.exit(1) + # Accepte YAML ou JSON indiff\u00e9remment + try: + import yaml as _yaml + json_data = _yaml.safe_load(raw) + except Exception: + json_data = json.loads(raw) + engine.render(json_data, args.output) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/archive/v1_pipeline/render_engine.py.bak2 b/archive/v1_pipeline/render_engine.py.bak2 new file mode 100644 index 0000000..d9775b5 --- /dev/null +++ b/archive/v1_pipeline/render_engine.py.bak2 @@ -0,0 +1,1955 @@ +""" +render_engine.py — Sliding Design System · Pernod Ricard +========================================================= +Moteur de rendu générique JSON → PPTX. + +Usage : + from render_engine import RenderEngine + engine = RenderEngine("theme.yaml", "components.yaml", "layouts.yaml") + engine.render(json_data, "output.pptx") + +Ou en ligne de commande : + python render_engine.py presentation.json output.pptx + +Architecture : + RenderEngine.render() + └── pour chaque slide : + 1. _resolve_layout() → charge la config du layout + 2. _render_background() → fond (C01) + 3. _render_signature() → logo, barre d'accent, footer (C02-C05) + 4. _measure_title() → calcule hauteur réelle du titre + 5. _render_title() → place le titre (C02) + 6. pour chaque content_zone : + _measure_component() → hauteur réelle + _render_component() → dispatch vers le bon renderer +""" + +from __future__ import annotations + +import json +import math +import os +import sys +from pathlib import Path +from typing import Any + +import yaml +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.text import PP_ALIGN +from pptx.util import Cm, Pt, Emu +from pptx.dml.color import RGBColor +from pptx.oxml.ns import qn +from lxml import etree + + +# ───────────────────────────────────────────────────────────────────────────── +# CONSTANTES +# ───────────────────────────────────────────────────────────────────────────── + +CM = 360000 # 1 cm = 360 000 EMU +PT = 12700 # 1 pt = 12 700 EMU +SLIDE_W = 12192000 # 33.87 cm +SLIDE_H = 6858000 # 19.05 cm +FOOTER_TOP = 18.35 # cm +FOOTER_H = 0.70 # cm + + +# ───────────────────────────────────────────────────────────────────────────── +# UTILITAIRES +# ───────────────────────────────────────────────────────────────────────────── + +def cm(v: float) -> int: + """Centimètres → EMU.""" + return int(v * CM) + + +def pt(v: float) -> int: + """Points → EMU (pour line_spacing, etc.).""" + return int(v * PT) + + +def hex_to_rgb(h: str) -> RGBColor: + """'#rrggbb' → RGBColor.""" + h = h.lstrip("#") + return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) + + +def resolve_ref(value: str, theme: dict) -> str: + """ + Résout une référence theme.* dans une valeur YAML. + Ex: 'theme.colors.primary.rose' → '#ff9166' + Retourne la valeur brute si ce n'est pas une ref. + """ + if not isinstance(value, str) or not value.startswith("theme."): + return value + parts = value.split(".")[1:] # retire 'theme' + node = theme + for p in parts: + if isinstance(node, dict) and p in node: + node = node[p] + else: + return value # ref non résolue → retourne telle quelle + return node + + +def add_text_box(slide, left, top, width, height, + text, font_name, font_size_pt, bold=False, italic=False, + color="#000000", align=PP_ALIGN.LEFT, word_wrap=True): + """Ajoute une text box sur le slide. Retourne le shape.""" + txBox = slide.shapes.add_textbox(cm(left), cm(top), cm(width), cm(height)) + tf = txBox.text_frame + tf.word_wrap = word_wrap + p = tf.paragraphs[0] + p.alignment = align + run = p.add_run() + run.text = text + run.font.name = font_name + run.font.size = Pt(font_size_pt) + run.font.bold = bold + run.font.italic = italic + run.font.color.rgb = hex_to_rgb(color) + return txBox + + +def add_rect(slide, left, top, width, height, fill_color, border_color=None, border_width_cm=0): + """Ajoute un rectangle plein. Retourne le shape.""" + shape = slide.shapes.add_shape( + 1, # MSO_SHAPE_TYPE.RECTANGLE + cm(left), cm(top), cm(width), cm(height) + ) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(fill_color) + if border_color and border_width_cm > 0: + shape.line.color.rgb = hex_to_rgb(border_color) + shape.line.width = cm(border_width_cm) + else: + shape.line.fill.background() + return shape + + +def add_line(slide, x1, y1, x2, y2, color="#e8e2d6", width_cm=0.03): + """Ajoute une ligne.""" + from pptx.util import Emu + connector = slide.shapes.add_connector(1, cm(x1), cm(y1), cm(x2), cm(y2)) + connector.line.color.rgb = hex_to_rgb(color) + connector.line.width = cm(width_cm) + return connector + + +def estimate_text_height(text: str, font_size_pt: float, + box_width_cm: float, line_spacing: float = 1.15) -> float: + """ + Estime la hauteur en cm d'un texte dans une boîte. + Heuristique : ~2.2 caractères par cm de largeur à 11pt, scaled par font_size. + """ + chars_per_line = max(1, int(box_width_cm * 2.2 * (11 / font_size_pt))) + lines = 0 + for paragraph in text.split("\n"): + if not paragraph.strip(): + lines += 0.5 + continue + lines += math.ceil(len(paragraph) / chars_per_line) + line_height_cm = font_size_pt * 0.035 * line_spacing + return lines * line_height_cm + + +# ───────────────────────────────────────────────────────────────────────────── +# RENDER ENGINE +# ───────────────────────────────────────────────────────────────────────────── + +class RenderEngine: + """ + Moteur principal. Charge les 3 YAML, expose render(json_data, output_path). + """ + + def __init__(self, theme_path: str, components_path: str, layouts_path: str): + with open(theme_path, encoding="utf-8") as f: + self.theme = yaml.safe_load(f) + with open(components_path, encoding="utf-8") as f: + self.components = yaml.safe_load(f)["components"] + with open(layouts_path, encoding="utf-8") as f: + data = yaml.safe_load(f) + self.layouts = data["layouts"] + + # Polices résolues (avec fallback si non installées) + self._font_display = self._resolve_font("display") + self._font_body = self._resolve_font("body") + + # Cycle couleur (index global, remis à zéro par présentation) + self._cycle_index = 0 + + # ── Résolution des polices ───────────────────────────────────────────── + + def _resolve_font(self, role: str) -> str: + font_cfg = self.theme["typography"][role] + primary = font_cfg["family"] + fallback = font_cfg.get("fallback", "Arial") + if self._is_font_available(primary): + return primary + return fallback + + def _is_font_available(self, font_name: str) -> bool: + fonts_dir = Path(self.theme["assets"].get("fonts_path", "assets/fonts/")) + if not fonts_dir.exists(): + return False + fn = font_name.lower().replace(" ", "") + return any(fn in f.stem.lower().replace(" ", "").replace("-", "") for f in fonts_dir.iterdir()) + + def patch_pptx_theme(self, output_path: str): + import zipfile as _zf, shutil as _sh + from lxml import etree as _et + tmp = output_path + ".tmp" + ns = "http://schemas.openxmlformats.org/drawingml/2006/main" + zin = _zf.ZipFile(output_path, "r") + zout = _zf.ZipFile(tmp, "w", _zf.ZIP_DEFLATED) + for item in zin.infolist(): + if item.filename != "ppt/theme/theme1.xml": + zout.writestr(item.filename, zin.read(item.filename)) + data = zin.read("ppt/theme/theme1.xml") + root = _et.fromstring(data) + fs = root.find(f".//{{{ns}}}fontScheme") + if fs is not None: + for tag, font in [("majorFont", self._font_display), ("minorFont", self._font_body)]: + node = fs.find(f"{{{ns}}}{tag}") + if node is not None: + lat = node.find(f"{{{ns}}}latin") + if lat is not None: + lat.set("typeface", font) + zout.writestr("ppt/theme/theme1.xml", _et.tostring(root, encoding="unicode").encode("utf-8")) + zin.close() + zout.close() + _sh.move(tmp, output_path) + + def _font(self, role: str) -> str: + return self._font_display if role == "display" else self._font_body + + # ── Résolution des refs theme ────────────────────────────────────────── + + def _r(self, value: Any) -> Any: + """Résout une ref theme.* si nécessaire.""" + return resolve_ref(value, self.theme) + + def _cycle_color(self) -> str: + colors = self.theme["colors"]["cycle"] + c = colors[self._cycle_index % len(colors)] + self._cycle_index += 1 + return c + + # ── Mesure ──────────────────────────────────────────────────────────── + + def _measure_title(self, layout_cfg: dict, slide_data: dict) -> float: + """ + Calcule la hauteur réelle occupée par le bloc titre + sous-titre. + Retourne la hauteur en cm. + """ + tz = layout_cfg.get("title_zone") + if not tz: + return 0.0 + + titre = slide_data.get("titre", "") + sous_titre = slide_data.get("sous_titre", "") + + font_override = tz.get("font_override", {}) + size_pt = font_override.get("size_pt", + self.theme["typography"]["sizes"]["slide_title"]) + width_cm = tz.get("width_cm", 30.0) + + h = estimate_text_height(titre, size_pt, width_cm, 1.1) + if sous_titre: + sub_size = tz.get("subtitle", {}).get("size_pt", + self.theme["typography"]["sizes"]["slide_subtitle"]) + h += estimate_text_height(sous_titre, sub_size, width_cm, 1.1) + h += 0.15 # marge entre titre et sous-titre + return max(h, 0.60) # minimum 0.6 cm + + def _measure_component(self, zone: dict, slide_data: dict) -> float: + """ + Estime la hauteur réelle d'une content_zone selon son contenu. + Retourne la hauteur en cm. Si non estimable, retourne height_cm du layout. + """ + comp_id = zone.get("component", "") + max_h = zone.get("height_cm", 14.0) + + # bullet_list + if comp_id == "C06": + bullets = slide_data.get("bullets", []) + if not bullets: + # cherche dans les sous-clés (two_cols, etc.) + return max_h + total_h = 0.0 + for b in bullets: + lvl = b.get("niveau", 1) + size_pt = [11, 10, 9][min(lvl - 1, 2)] + w = zone.get("width_cm", 28.0) + total_h += estimate_text_height( + b.get("texte", ""), size_pt, w - (lvl - 1) * 0.5) + total_h += [0.14, 0.08, 0.04][min(lvl - 1, 2)] + return min(total_h + 0.3, max_h) + + # text_paragraph (executive_summary blocs) + if comp_id == "C07": + # cherche le champ associé dans slide_data + for key in ["situation", "complication", "resolution", + "contenu", "description"]: + if key in slide_data: + txt = slide_data[key] + h = estimate_text_height(txt, 11, zone.get("width_cm", 28.0)) + return min(h + 0.6, max_h) # +0.6 pour le titre de bloc + return max_h + + # kpi_grid → hauteur calculée selon nb items + if comp_id == "C09": + items = slide_data.get("items", []) + n = len(items) + grid = {2: (2, 1), 3: (3, 1), 4: (2, 2), 5: (3, 2), 6: (3, 2)} + cols, rows = grid.get(n, (3, 2)) + card_h = 4.5 # hauteur d'une carte KPI en cm + gap = 0.4 + return min(rows * card_h + (rows - 1) * gap, max_h) + + # big_stat → hauteur fixe + if comp_id == "C10": + return max_h + + # Pour tous les autres composants visuels complexes → hauteur max + return max_h + + # ── Rendu principal ─────────────────────────────────────────────────── + + def render(self, json_data: dict | str, output_path: str): + """ + Point d'entrée. Accepte un dict ou une chaîne JSON. + Produit le fichier PPTX à output_path. + """ + if isinstance(json_data, str): + json_data = json.loads(json_data) + + prs = Presentation() + prs.slide_width = Emu(SLIDE_W) + prs.slide_height = Emu(SLIDE_H) + + # Supprime les layouts par défaut (on dessine tout manuellement) + blank_layout = prs.slide_layouts[6] # layout "blank" + + self._cycle_index = 0 + slides = json_data.get("slides", []) + + for i, slide_data in enumerate(slides): + slide = prs.slides.add_slide(blank_layout) + layout_name = slide_data.get("layout", "default_bullets") + self._render_slide(slide, slide_data, layout_name, i + 1, len(slides)) + + prs.save(output_path) + self.patch_pptx_theme(output_path) + print(f"✓ PPTX généré : {output_path} ({len(slides)} slides)") + + def _render_slide(self, slide, slide_data: dict, layout_name: str, + slide_num: int, total: int): + """Orchestre le rendu d'un slide complet.""" + layout_cfg = self.layouts.get(layout_name) + if not layout_cfg: + print(f" ⚠ Layout inconnu '{layout_name}' → fallback default_bullets") + layout_cfg = self.layouts["default_bullets"] + layout_name = "default_bullets" + + # ── 1. Background ───────────────────────────────────────────────── + self._render_background(slide, layout_cfg) + + # ── 2. Signature (footer, logo, accent bar) ─────────────────────── + self._render_footer(slide, layout_name, slide_num) + self._render_logo(slide, layout_name) + + # ── 3. Titre + mesure ───────────────────────────────────────────── + title_h = self._measure_title(layout_cfg, slide_data) + title_bottom = self._render_title(slide, layout_cfg, slide_data, title_h) + self._render_accent_bar(slide, layout_name, title_h) + + # ── 4. Content zones ────────────────────────────────────────────── + zones = layout_cfg.get("content_zones") or [] + # cursor : commence juste sous le titre + cursor_y = title_bottom + 0.20 if title_bottom else 2.80 + # zone max disponible (jusqu'au footer ou bas du slide) + max_bottom = FOOTER_TOP - 0.30 # laisse 0.3 cm au-dessus du footer + + for zone in zones: + # Zones optionnelles absentes du JSON → skip + if zone.get("optional") and not self._zone_has_data(zone, slide_data): + continue + + # Positions : priorité aux coords fixes, sinon on utilise le curseur + z_left = zone.get("left_cm", 1.50) + z_top = zone.get("top_cm", cursor_y) + z_width = zone.get("width_cm", 30.87) + + # Calcul de la hauteur réelle + measured_h = self._measure_component(zone, slide_data) + z_height = min(measured_h, max_bottom - z_top) + if z_height <= 0: + continue # plus de place + + # Mise à jour du curseur (uniquement pour les zones sans top fixe) + if "top_cm" not in zone: + cursor_y = z_top + z_height + 0.25 + + self._render_zone(slide, zone, slide_data, + z_left, z_top, z_width, z_height) + + # ── Background ──────────────────────────────────────────────────────── + + def _render_background(self, slide, layout_cfg: dict): + """Rend le fond du slide (C01).""" + bg = layout_cfg.get("background", {}) + color = self._r(bg.get("color", "#ffffff")) + + if bg.get("diagonal_split"): + color_right = self._r(bg.get("color_right", "#023466")) + angle = bg.get("diagonal_angle_deg", 15) + self._render_diagonal_background(slide, color, color_right, angle) + else: + add_rect(slide, 0, 0, 33.87, 19.05, color) + + def _render_diagonal_background(self, slide, color_left: str, + color_right: str, angle_deg: float): + """Fond splitté diagonal : rectangle gauche + triangle droit.""" + # Panneau gauche plein + add_rect(slide, 0, 0, 33.87, 19.05, color_left) + # Panneau droit via freeform (triangle) + # La diagonale va du point (split_x, 0) au point (split_x - offset, 19.05) + split_x = 20.0 # cm — point haut de la diagonale + offset = 19.05 * math.tan(math.radians(angle_deg)) + split_x_bottom = split_x - offset + + from pptx.util import Emu + from pptx.oxml.ns import qn + + # Utilise add_shape freeform via XML pour le triangle + sp = slide.shapes.add_shape(1, + cm(split_x_bottom), cm(0), + cm(33.87 - split_x_bottom), cm(19.05)) + sp.fill.solid() + sp.fill.fore_color.rgb = hex_to_rgb(color_right) + sp.line.fill.background() + + # Note : python-pptx ne supporte pas les freeforms nativement. + # Pour un vrai triangle, il faudrait manipuler l'XML OOXML directement. + # Cette version utilise un rectangle approché — suffisant pour l'aperçu. + # TODO : implémenter la forme triangulaire via lxml si rendu exact requis. + + # ── Signature ───────────────────────────────────────────────────────── + + def _render_footer(self, slide, layout_name: str, slide_num: int): + """Rend le footer PR (C04).""" + footer_cfg = self.theme["signature"]["footer"] + hidden_on = footer_cfg.get("hidden_on", []) + if layout_name in hidden_on: + return + + top = FOOTER_TOP + h = FOOTER_H + w = 33.87 + + # Fond blanc + add_rect(slide, 0, top, w, h, "#ffffff") + # Bordure top + add_line(slide, 0, top, w, top, "#e8e2d6", 0.03) + + # Numéro de slide + add_text_box(slide, 0.80, top + 0.10, 1.50, 0.50, + str(slide_num), self._font_body, 8, + color="#000a32", align=PP_ALIGN.LEFT) + + # Séparateur vertical + add_line(slide, 1.50, top + 0.10, 1.50, top + 0.60, "#7fa5d0", 0.03) + + # "Pernod Ricard" + add_text_box(slide, 1.70, top + 0.10, 5.00, 0.50, + "Pernod Ricard", self._font_body, 8, + color="#000a32", align=PP_ALIGN.LEFT) + + # Tagline à droite + add_text_box(slide, 15.00, top + 0.10, 18.00, 0.50, + "DATA GOVERNANCE DATA MANAGEMENT", + self._font_body, 7, + color="#48545a", align=PP_ALIGN.RIGHT) + + def _render_logo(self, slide, layout_name: str): + """Insère le logo PR top-left si le fichier assets/logo_pr_sun.png existe.""" + logo_cfg = self.theme["signature"]["logo_topbar"] + visible_on = logo_cfg.get("visible_on", []) + if layout_name not in visible_on: + return + + logo_path = logo_cfg.get("file", "assets/logo_pr_sun.png") + if not os.path.exists(logo_path): + # Logo absent → on dessine un proxy (cercle orange petit) + shape = slide.shapes.add_shape(9, # ellipse + cm(logo_cfg["position_left_cm"]), + cm(logo_cfg["position_top_cm"]), + cm(logo_cfg["height_cm"]), + cm(logo_cfg["height_cm"])) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb("#ff9166") + shape.line.fill.background() + return + + slide.shapes.add_picture( + logo_path, + cm(logo_cfg["position_left_cm"]), + cm(logo_cfg["position_top_cm"]), + height=cm(logo_cfg["height_cm"]) + ) + + def _render_accent_bar(self, slide, layout_name: str, title_h: float): + """Barre verticale rose à gauche du titre (C03).""" + sig = self.theme["signature"]["accent_bar"] + if layout_name not in sig.get("visible_on", []): + return + + bar_h = max(title_h, 0.60) + add_rect(slide, + sig["position_left_cm"], 0.45, + sig["width_cm"], bar_h, + sig["color"]) + + # ── Titre ───────────────────────────────────────────────────────────── + + def _render_title(self, slide, layout_cfg: dict, + slide_data: dict, title_h: float) -> float: + """Rend le titre et le sous-titre. Retourne le y_bottom en cm.""" + tz = layout_cfg.get("title_zone") + if not tz: + return 0.0 + + titre = slide_data.get("titre", "") + sous_titre = slide_data.get("sous_titre", "") + + left = tz.get("left_cm", 1.80) + top = tz.get("top_cm", 0.45) + width = tz.get("width_cm", 30.00) + + font_override = tz.get("font_override", {}) + size_pt = font_override.get("size_pt", + self.theme["typography"]["sizes"]["slide_title"]) + bold = font_override.get("bold", True) + color = self._r(font_override.get("color", + self.theme["colors"]["text"]["on_white"])) + + # Titre principal + h_titre = estimate_text_height(titre, size_pt, width, 1.1) + h_titre = max(h_titre, size_pt * 0.035 + 0.1) + add_text_box(slide, left, top, width, h_titre + 0.20, + titre, self._font_display, size_pt, + bold=bold, color=color) + + current_bottom = top + h_titre + 0.20 + + # Sous-titre + if sous_titre: + sub_cfg = tz.get("subtitle", {}) + sub_size = sub_cfg.get("size_pt", + self.theme["typography"]["sizes"]["slide_subtitle"]) + sub_color = self._r(sub_cfg.get("color", + self.theme["colors"]["text"]["subtitle"])) + margin = sub_cfg.get("margin_top_cm", 0.10) + add_text_box(slide, left, current_bottom + margin, + width, 0.60, + sous_titre, self._font_body, sub_size, + color=sub_color) + current_bottom += margin + 0.60 + + return current_bottom + + # ── Dispatch des zones ──────────────────────────────────────────────── + + def _zone_has_data(self, zone: dict, slide_data: dict) -> bool: + """Vérifie si une zone optionnelle a des données dans le JSON.""" + comp = zone.get("component", "") + if comp == "C07": + return any(k in slide_data for k in + ["description", "situation", "complication", "resolution", "contenu"]) + return True + + def _render_zone(self, slide, zone: dict, slide_data: dict, + left: float, top: float, width: float, height: float): + """Dispatche vers le renderer du composant.""" + comp = zone.get("component", "") + zone_type = zone.get("type", "") + + # Séparateurs (pas de composant associé) + if zone_type == "vertical_line": + add_line(slide, zone.get("x_cm", left), + zone.get("top_cm", top), + zone.get("x_cm", left), + zone.get("top_cm", top) + zone.get("height_cm", height), + self._r(zone.get("color", "#e8e2d6")), + zone.get("width_cm", 0.03)) + return + if zone_type == "horizontal_line": + y = zone.get("y_cm", top) + add_line(slide, left, y, left + width, y, + self._r(zone.get("color", "#e8e2d6")), + zone.get("width_cm", 0.03)) + return + + dispatch = { + "C06": self._render_bullet_list, + "C07": self._render_text_paragraph, + "C08": self._render_quote_block, + "C09": self._render_kpi_grid, + "C10": self._render_big_stat, + "C11": self._render_data_table, + "C12": self._render_chart_placeholder, + "C13": self._render_callout_box, + "C14": self._render_benchmark, + "C15": self._render_matrix_2x2, + "C16": self._render_pyramid, + "C17": self._render_circular_diagram, + "C18": self._render_from_to_pairs, + "C19": self._render_numbered_steps, + "C20": self._render_chevrons, + "C21": self._render_gantt, + "C22": self._render_timeline, + "C23": self._render_org_chart, + "C24": self._render_raci, + "C25": self._render_decision_tree, + "C26": self._render_recommendation_sidebar, + } + + renderer = dispatch.get(comp) + if renderer: + renderer(slide, zone, slide_data, left, top, width, height) + else: + # Composant inconnu → zone grise placeholder + self._render_placeholder(slide, left, top, width, height, comp) + + # ── Renderers des composants ────────────────────────────────────────── + + def _render_placeholder(self, slide, left, top, width, height, label="?"): + """Zone placeholder pour composants non encore implémentés.""" + add_rect(slide, left, top, width, height, "#f5f1ea") + add_text_box(slide, left + 0.5, top + height / 2 - 0.3, + width - 1, 0.6, + f"[ {label} — à implémenter ]", + self._font_body, 10, color="#9a9a9a", + align=PP_ALIGN.CENTER) + + # C06 — bullet_list ──────────────────────────────────────────────────── + + def _render_bullet_list(self, slide, zone, slide_data, + left, top, width, height): + """Bullets hiérarchisés L1/L2/L3.""" + # Cherche les bullets dans le JSON (champ direct ou dans une colonne) + zone_id = zone.get("id", "") + if "col_left" in zone_id: + col_data = slide_data.get("left", {}) + elif "col_right" in zone_id: + col_data = slide_data.get("right", {}) + else: + col_data = slide_data + + bullets = col_data.get("bullets", []) + if not bullets: + return + + txBox = slide.shapes.add_textbox( + cm(left), cm(top), cm(width), cm(height)) + tf = txBox.text_frame + tf.word_wrap = True + + sizes = {1: 11, 2: 10, 3: 9} + colors = { + 1: "#000a32", + 2: self.theme["colors"]["text"]["body"], + 3: self.theme["colors"]["text"]["body"], + } + indents = {1: 0, 2: 0.5, 3: 1.0} + markers = {1: "• ", 2: "– ", 3: "▪ "} + space_before = {1: Pt(4), 2: Pt(2), 3: Pt(1)} + + first = True + for b in bullets: + lvl = b.get("niveau", 1) + text = b.get("texte", "") + + p = tf.paragraphs[0] if first else tf.add_paragraph() + first = False + p.space_before = space_before.get(lvl, Pt(4)) + p.alignment = PP_ALIGN.LEFT + + # Indentation via l'XML (level) + pPr = p._p.get_or_add_pPr() + pPr.set("lvl", str(lvl - 1)) + + run = p.add_run() + run.text = markers[lvl] + text + run.font.name = self._font_body + run.font.size = Pt(sizes[lvl]) + run.font.bold = (lvl == 1) + run.font.color.rgb = hex_to_rgb(colors[lvl]) + + # Sous-items récursifs + for sub in b.get("sous_items", []) or []: + p2 = tf.add_paragraph() + p2.alignment = PP_ALIGN.LEFT + run2 = p2.add_run() + run2.text = " – " + sub + run2.font.name = self._font_body + run2.font.size = Pt(9) + run2.font.color.rgb = hex_to_rgb(self.theme["colors"]["text"]["body"]) + + # C07 — text_paragraph ───────────────────────────────────────────────── + + def _render_text_paragraph(self, slide, zone, slide_data, + left, top, width, height): + """Bloc de texte libre avec titre de bloc optionnel.""" + zone_id = zone.get("id", "") + + # Mapping zone_id → champ JSON + field_map = { + "bloc_situation": ("Situation", "situation"), + "bloc_complication": ("Complication", "complication"), + "bloc_resolution": ("Résolution", "resolution"), + "col_left": (None, "left"), + "col_right": (None, "right"), + "description_bloc": (None, "description"), + "contact": (None, "contacts"), + "next_steps": (None, "message"), + } + + titre_bloc, field = field_map.get(zone_id, (None, "contenu")) + titre_couleur = self._r(zone.get("titre_couleur", + self.theme["colors"]["primary"]["dark_blue"])) + font_override = zone.get("font_override", {}) + + cur_top = top + + # Titre de bloc + if titre_bloc: + add_text_box(slide, left, cur_top, width, 0.50, + titre_bloc, self._font_body, 13, + bold=True, color=titre_couleur) + cur_top += 0.55 + + # Contenu + raw = slide_data.get(field, "") + if isinstance(raw, dict): + titre_col = raw.get("titre", "") + contenu = raw.get("contenu", "") + if titre_col: + add_text_box(slide, left, cur_top, width, 0.45, + titre_col, self._font_body, 13, + bold=True, color=titre_couleur) + cur_top += 0.50 + raw = contenu + + if raw: + color = font_override.get("color", self.theme["colors"]["text"]["body"]) + size_pt = font_override.get("size_pt", 11) + add_text_box(slide, left, cur_top, width, + height - (cur_top - top), + str(raw), self._font_body, size_pt, + color=color) + + # C08 — quote_block ──────────────────────────────────────────────────── + + def _render_quote_block(self, slide, zone, slide_data, + left, top, width, height): + """Citation / key message avec guillemets Cormorant.""" + citation = slide_data.get("message") or slide_data.get("citation", "") + auteur = slide_data.get("auteur", "") + fonction = slide_data.get("fonction", "") + + # Guillemet décoratif + add_text_box(slide, left, top + 0.3, 2.0, 1.5, + "\u201C", self._font_display, 72, + color=self.theme["colors"]["primary"]["bright_blue"]) + + # Message + add_text_box(slide, left + 1.5, top + 1.2, + width - 1.5, height - 2.0, + citation, self._font_display, 22, + color=self.theme["colors"]["primary"]["dark_blue"]) + + # Attribution + if auteur or fonction: + attr = f"{auteur} {fonction}".strip() + add_text_box(slide, left + 1.5, + top + height - 1.2, + width - 1.5, 0.60, + attr, self._font_body, 10, + color=self.theme["colors"]["text"]["body"]) + + # C09 — kpi_grid ─────────────────────────────────────────────────────── + + def _render_kpi_grid(self, slide, zone, slide_data, + left, top, width, height): + """Grille de cartes KPI adaptative.""" + items = slide_data.get("items", []) + if not items: + return + + n = len(items) + grid = {2: (2, 1), 3: (3, 1), 4: (2, 2), 5: (3, 2), 6: (3, 2)} + cols, rows = grid.get(n, (3, 2)) + gap = 0.40 + + card_w = (width - (cols - 1) * gap) / cols + card_h = (height - (rows - 1) * gap) / rows + + for i, item in enumerate(items): + col = i % cols + row = i // cols + x = left + col * (card_w + gap) + y = top + row * (card_h + gap) + + color = item.get("couleur") or self._cycle_color() + color = self._r(color) + header_h = 0.55 + + # Header coloré + add_rect(slide, x, y, card_w, header_h, color) + add_text_box(slide, x + 0.2, y + 0.10, + card_w - 0.4, header_h - 0.10, + item.get("titre", ""), + self._font_body, 9, + bold=True, color="#ffffff") + + # Body beige + add_rect(slide, x, y + header_h, card_w, + card_h - header_h, + self.theme["colors"]["backgrounds"]["content_area"]) + + # Valeur en gros + val_h = card_h - header_h - 1.0 + add_text_box(slide, x + 0.2, y + header_h + 0.3, + card_w - 0.4, val_h, + item.get("valeur", ""), + self._font_display, 32, + bold=True, + color=self.theme["colors"]["primary"]["rose"], + align=PP_ALIGN.CENTER) + + # Sous-titre + if item.get("sous_titre"): + add_text_box(slide, x + 0.2, + y + card_h - 0.8, + card_w - 0.4, 0.70, + item["sous_titre"], + self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # C10 — big_stat_display ─────────────────────────────────────────────── + + def _render_big_stat(self, slide, zone, slide_data, + left, top, width, height): + """Chiffre unique centré en très grand format.""" + valeur = slide_data.get("valeur", "") + label = slide_data.get("label", "") + source = slide_data.get("source", "") + + center_top = top + (height - 4.0) / 2 + + # Valeur + add_text_box(slide, left, center_top, width, 2.80, + valeur, self._font_display, 72, + bold=True, + color=self.theme["colors"]["primary"]["rose"], + align=PP_ALIGN.CENTER) + + if label: + add_text_box(slide, left, center_top + 2.90, width, 0.70, + label, self._font_body, 11, + color=self.theme["colors"]["text"]["body"], + align=PP_ALIGN.CENTER) + if source: + add_text_box(slide, left, center_top + 3.70, width, 0.50, + f"Source : {source}", self._font_body, 9, + color=self.theme["colors"]["text"]["caption"], + align=PP_ALIGN.CENTER) + + # C11 — data_table ───────────────────────────────────────────────────── + + def _render_data_table(self, slide, zone, slide_data, + left, top, width, height): + """Tableau structuré avec header bleu foncé et lignes alternées.""" + headers = slide_data.get("headers", []) + rows = slide_data.get("rows", []) + if not headers: + return + + highlight_col = slide_data.get("highlight_col") + col_widths_pct = slide_data.get("col_widths") + + n_cols = len(headers) + header_h = 0.65 + available_h = height - header_h + row_h = min(available_h / max(len(rows), 1), 0.80) + + # Largeurs de colonnes + if col_widths_pct: + col_widths = [w * width for w in col_widths_pct] + else: + col_widths = [width / n_cols] * n_cols + + # Header + x = left + for j, h in enumerate(headers): + add_rect(slide, x, top, col_widths[j], header_h, + self.theme["colors"]["primary"]["dark_blue"]) + add_text_box(slide, x + 0.15, top + 0.10, + col_widths[j] - 0.3, header_h - 0.15, + str(h), self._font_body, 9, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + x += col_widths[j] + + # Lignes + odd_bg = "#ffffff" + even_bg = self.theme["colors"]["backgrounds"]["content_area"] + highlight_bg = self.theme["colors"]["backgrounds"]["highlight_box"] + + for i, row in enumerate(rows): + y = top + header_h + i * row_h + x = left + for j, cell in enumerate(row): + bg = highlight_bg if j == highlight_col else ( + odd_bg if i % 2 == 0 else even_bg) + add_rect(slide, x, y, col_widths[j], row_h, bg) + add_text_box(slide, x + 0.15, y + 0.08, + col_widths[j] - 0.3, row_h - 0.10, + str(cell), self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + x += col_widths[j] + # Ligne séparatrice + add_line(slide, left, y + row_h, left + width, y + row_h, + "#e8e2d6", 0.02) + + # C12 — chart_placeholder ────────────────────────────────────────────── + + def _render_chart_placeholder(self, slide, zone, slide_data, + left, top, width, height): + """ + Graphique simplifié (bar chart) généré avec python-pptx Chart. + Pour un rendu avancé, remplacer par openpyxl + pptx chart data. + """ + from pptx.chart.data import ChartData + from pptx.enum.chart import XL_CHART_TYPE + + data_items = slide_data.get("data", []) + chart_type = slide_data.get("chart_type", "bar") + if not data_items: + self._render_placeholder(slide, left, top, width, height, "C12 chart") + return + + chart_data = ChartData() + chart_data.categories = [str(d.get("label", f"Item {i+1}")) + for i, d in enumerate(data_items)] + chart_data.add_series("", [float(d.get("valeur", 0)) + for d in data_items]) + + xl_type = { + "bar": XL_CHART_TYPE.BAR_CLUSTERED, + "line": XL_CHART_TYPE.LINE, + "pie": XL_CHART_TYPE.PIE, + "donut": XL_CHART_TYPE.DOUGHNUT, + }.get(chart_type, XL_CHART_TYPE.BAR_CLUSTERED) + + chart = slide.shapes.add_chart( + xl_type, + cm(left), cm(top), cm(width), cm(height), + chart_data + ).chart + + # Supprimer le titre du chart (on a déjà le titre du slide) + chart.has_title = False + chart.has_legend = False + + # Couleur des barres + series = chart.series[0] + fill = series.format.fill + fill.solid() + fill.fore_color.rgb = hex_to_rgb( + self.theme["colors"]["primary"]["bright_blue"]) + + # C13 — callout_box ──────────────────────────────────────────────────── + + def _render_callout_box(self, slide, zone, slide_data, + left, top, width, height): + """Encadré d'insight jaune.""" + insight = slide_data.get("insight", "") + titre = slide_data.get("titre_insight", "") + + # Fond + add_rect(slide, left, top, width, height, + self.theme["colors"]["backgrounds"]["highlight_box"], + self.theme["colors"]["secondary"]["maize_yellow"], 0.05) + + cur_top = top + 0.30 + if titre: + add_text_box(slide, left + 0.30, cur_top, + width - 0.60, 0.50, + titre, self._font_body, 12, + bold=True, + color=self.theme["colors"]["primary"]["rose"]) + cur_top += 0.55 + + if insight: + add_text_box(slide, left + 0.30, cur_top, + width - 0.60, height - (cur_top - top) - 0.30, + insight, self._font_body, 10, + color=self.theme["colors"]["text"]["body"]) + + # C14 — benchmark_bar ────────────────────────────────────────────────── + + def _render_benchmark(self, slide, zone, slide_data, + left, top, width, height): + """Barres horizontales de benchmark.""" + criteria = slide_data.get("criteria", []) + actors = slide_data.get("actors", []) + scores = slide_data.get("scores", []) + if not criteria or not actors: + return + + colors_actors = slide_data.get("couleurs_acteurs") or [ + self.theme["colors"]["primary"]["bright_blue"], + self.theme["colors"]["primary"]["rose"], + self.theme["colors"]["secondary"]["barley_green"], + self.theme["colors"]["secondary"]["maize_yellow"], + ] + + n_crit = len(criteria) + n_act = len(actors) + label_w = 5.00 + bar_area_w = width - label_w + row_h = height / n_crit + bar_h = 0.30 + bar_gap = 0.10 + + # Légende acteurs (en haut) + for j, actor in enumerate(actors): + add_rect(slide, left + label_w + j * 2.0, top - 0.50, + 0.25, 0.25, colors_actors[j % len(colors_actors)]) + add_text_box(slide, left + label_w + j * 2.0 + 0.30, + top - 0.55, 1.5, 0.35, + actor, self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + for i, crit in enumerate(criteria): + y = top + i * row_h + + # Label critère + add_text_box(slide, left, y + row_h / 2 - 0.20, + label_w - 0.30, 0.40, + crit, self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # Barres par acteur + for j in range(n_act): + score = 0 + if i < len(scores) and j < len(scores[i]): + score = float(scores[i][j]) + bar_w = (score / 100) * bar_area_w + + bar_y = y + (row_h - n_act * (bar_h + bar_gap)) / 2 + j * (bar_h + bar_gap) + add_rect(slide, left + label_w, bar_y, + max(bar_w, 0.05), bar_h, + colors_actors[j % len(colors_actors)]) + + # C15 — matrix_bubble ───────────────────────────────────────────────── + + def _render_matrix_2x2(self, slide, zone, slide_data, + left, top, width, height): + """Matrice 2×2 avec bulles positionnées.""" + axis_x = slide_data.get("axis_x", {}) + axis_y = slide_data.get("axis_y", {}) + items = slide_data.get("items", []) + + ax_label = str(axis_x.get("label", "")) + ay_label = str(axis_y.get("label", "")) + + # Marges pour les labels d'axes + margin_left = 1.50 + margin_bottom = 0.80 + plot_w = width - margin_left + plot_h = height - margin_bottom + + # Axes + add_line(slide, left + margin_left, top, + left + margin_left, top + plot_h, + "#000a32", 0.05) + add_line(slide, left + margin_left, top + plot_h, + left + width, top + plot_h, + "#000a32", 0.05) + + # Lignes de quadrant + mid_x = left + margin_left + plot_w / 2 + mid_y = top + plot_h / 2 + add_line(slide, mid_x, top, mid_x, top + plot_h, "#48545a", 0.02) + add_line(slide, left + margin_left, mid_y, + left + width, mid_y, "#48545a", 0.02) + + # Labels axes + add_text_box(slide, left + margin_left + plot_w / 2 - 2, + top + plot_h + 0.10, + 4, 0.40, ax_label, + self._font_body, 9, color="#48545a", + align=PP_ALIGN.CENTER) + add_text_box(slide, left, top + plot_h / 2 - 0.30, + margin_left - 0.10, 0.60, ay_label, + self._font_body, 9, color="#48545a", + align=PP_ALIGN.RIGHT) + + # Labels extremes + add_text_box(slide, left + margin_left - 0.5, top + plot_h - 0.20, + 0.8, 0.30, "Low", self._font_body, 8, color="#48545a") + add_text_box(slide, left + margin_left - 0.5, top, + 0.8, 0.30, "High", self._font_body, 8, color="#48545a") + add_text_box(slide, left + margin_left, top + plot_h, + 0.8, 0.30, "Low", self._font_body, 8, color="#48545a") + add_text_box(slide, left + width - 1.0, top + plot_h, + 1.0, 0.30, "High", self._font_body, 8, color="#48545a", + align=PP_ALIGN.RIGHT) + + colors = self.theme["colors"]["cycle"] + for i, item in enumerate(items): + x_pct = item.get("x", 50) / 100 + y_pct = 1 - item.get("y", 50) / 100 # inverser y (0 = bas) + size_factor = item.get("taille", 2) + diameter = 0.30 + (size_factor - 1) * 0.15 + color = self._r(item.get("couleur") or colors[i % len(colors)]) + + bx = left + margin_left + x_pct * plot_w - diameter / 2 + by = top + y_pct * plot_h - diameter / 2 + + shape = slide.shapes.add_shape(9, # ellipse + cm(bx), cm(by), cm(diameter), cm(diameter)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(color) + shape.fill.fore_color.theme_color + shape.line.fill.background() + # Opacité via XML + spPr = shape._element.spPr + solidFill = spPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}solidFill') + if solidFill is not None: + srgbClr = solidFill.find('{http://schemas.openxmlformats.org/drawingml/2006/main}srgbClr') + if srgbClr is not None: + alpha = etree.SubElement(srgbClr, + '{http://schemas.openxmlformats.org/drawingml/2006/main}alpha') + alpha.set('val', '75000') # 75% opacité + + # Label + add_text_box(slide, bx - 0.5, by + diameter + 0.05, + diameter + 1.0, 0.35, + item.get("label", ""), + self._font_body, 8, + color=self.theme["colors"]["primary"]["dark_blue"], + align=PP_ALIGN.CENTER) + + # C16 — pyramid_level ───────────────────────────────────────────────── + + def _render_pyramid(self, slide, zone, slide_data, + left, top, width, height): + """Pyramide hiérarchique.""" + levels = slide_data.get("levels", []) + if not levels: + return + + n = len(levels) + colors_default = [ + "#7fa5d0", "#000a32", "#bad9ff", "#ffcf0f", "#d9d9c4" + ] + level_h = height / n + center_x = left + width / 2 + max_w = width * 0.55 + callout_right_x = left + width * 0.70 + + for i, level in enumerate(levels): + rank = i + 1 + frac = rank / n + lvl_w = max_w * frac + lvl_left = center_x - lvl_w / 2 + lvl_top = top + i * level_h + color = self._r(level.get("couleur") or colors_default[i % len(colors_default)]) + + add_rect(slide, lvl_left, lvl_top, lvl_w, level_h - 0.05, color) + add_text_box(slide, lvl_left, lvl_top + level_h / 2 - 0.20, + lvl_w, 0.40, + level.get("label", ""), + self._font_body, 9, + bold=True, + color="#ffffff" if i in [1] else "#000a32", + align=PP_ALIGN.CENTER) + + # Callout latéral + if level.get("description"): + side = "right" if i % 2 == 0 else "left" + if side == "right": + add_line(slide, lvl_left + lvl_w, lvl_top + level_h / 2, + callout_right_x, lvl_top + level_h / 2, + "#48545a", 0.02) + add_text_box(slide, callout_right_x + 0.10, + lvl_top + level_h / 2 - 0.20, + left + width - callout_right_x - 0.20, + 0.60, level["description"], + self._font_body, 8, + color=self.theme["colors"]["text"]["body"]) + else: + callout_left_x = left + add_line(slide, lvl_left, lvl_top + level_h / 2, + callout_left_x + width * 0.25, + lvl_top + level_h / 2, "#48545a", 0.02) + add_text_box(slide, callout_left_x, + lvl_top + level_h / 2 - 0.20, + width * 0.24, 0.60, + level["description"], + self._font_body, 8, + color=self.theme["colors"]["text"]["body"], + align=PP_ALIGN.RIGHT) + + # C17 — circular_segment ─────────────────────────────────────────────── + + def _render_circular_diagram(self, slide, zone, slide_data, + left, top, width, height): + """Diagramme circulaire (approximation par secteurs via rectangles colorés).""" + segments = slide_data.get("segments", []) + if not segments: + return + + colors_default = self.theme["colors"]["cycle"] + n = len(segments) + + # Cercle central approximé (zones colorées en 2×N) + # Note : python-pptx ne supporte pas les pie charts custom facilement. + # On utilise des ellipses + médaillon central. + cx = left + width * 0.35 + cy = top + height / 2 + r = min(height * 0.38, width * 0.25) + + # Secteurs simulés par des rectangles colorés en arc + # (approximation visuelle — pour un vrai pie, utiliser chart_data) + angle_step = 360 / n + for i, seg in enumerate(segments): + color = self._r(seg.get("couleur") or colors_default[i % len(colors_default)]) + # Ellipse approximant un secteur + angle_rad = math.radians(i * angle_step) + sx = cx + r * 0.5 * math.cos(angle_rad) + sy = cy + r * 0.5 * math.sin(angle_rad) + shape = slide.shapes.add_shape(9, + cm(sx - r * 0.45), cm(sy - r * 0.45), + cm(r * 0.90), cm(r * 0.90)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(color) + shape.line.fill.background() + + # Médaillon central blanc + shape = slide.shapes.add_shape(9, + cm(cx - r * 0.35), cm(cy - r * 0.35), + cm(r * 0.70), cm(r * 0.70)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb("#ffffff") + shape.line.fill.background() + + # Légende à droite + leg_left = left + width * 0.55 + leg_top = top + (height - n * 1.4) / 2 + + for i, seg in enumerate(segments): + color = self._r(seg.get("couleur") or colors_default[i % len(colors_default)]) + y = leg_top + i * 1.40 + + # Pastille + shape = slide.shapes.add_shape(9, + cm(leg_left), cm(y + 0.05), + cm(0.35), cm(0.35)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(color) + shape.line.fill.background() + + # Num + label + add_text_box(slide, leg_left + 0.50, y, + width - (leg_left - left) - 0.60, 0.40, + f"0{i+1} {seg.get('label', '')}", + self._font_body, 10, bold=True, + color=self.theme["colors"]["primary"]["dark_blue"]) + if seg.get("description"): + add_text_box(slide, leg_left + 0.50, y + 0.42, + width - (leg_left - left) - 0.60, 0.70, + seg["description"], + self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # C18 — from_to_pair ─────────────────────────────────────────────────── + + def _render_from_to_pairs(self, slide, zone, slide_data, + left, top, width, height): + """Paires FROM → TO.""" + pairs = slide_data.get("pairs", []) + if not pairs: + return + + # En-tête FROM / TO + mid_x = left + width * 0.42 + add_text_box(slide, left, top, width * 0.40, 0.50, + "FROM", self._font_body, 11, + bold=True, + color=self.theme["semantic"]["arrow_color"]) + add_text_box(slide, mid_x + 0.80, top, width * 0.40, 0.50, + "TO", self._font_body, 11, + bold=True, + color=self.theme["semantic"]["arrow_color"]) + + row_h = (height - 0.60) / max(len(pairs), 1) + for i, pair in enumerate(pairs): + y = top + 0.60 + i * row_h + # FROM (atténué) + add_text_box(slide, left, y + 0.08, + width * 0.38, row_h - 0.15, + pair.get("from", ""), + self._font_body, 11, + color=self.theme["semantic"]["from_color"]) + + # Flèche + add_text_box(slide, mid_x - 0.20, y + 0.05, 0.60, 0.40, + "›", self._font_body, 18, bold=True, + color=self.theme["semantic"]["arrow_color"], + align=PP_ALIGN.CENTER) + + # TO (affirmé) + add_text_box(slide, mid_x + 0.50, y + 0.08, + width - mid_x - 0.50, row_h - 0.15, + pair.get("to", ""), + self._font_body, 11, + bold=True, + color=self.theme["semantic"]["to_color"]) + + # Séparateur + if i < len(pairs) - 1: + add_line(slide, left, y + row_h, + left + width, y + row_h, + "#e8e2d6", 0.02) + + # C19 — step_item ────────────────────────────────────────────────────── + + def _render_numbered_steps(self, slide, zone, slide_data, + left, top, width, height): + """Étapes numérotées verticalement.""" + steps = slide_data.get("steps", []) + if not steps: + return + + row_h = height / max(len(steps), 1) + badge_size = 0.70 + + for i, step in enumerate(steps): + y = top + i * row_h + + # Badge carré + add_rect(slide, left, y + (row_h - badge_size) / 2, + badge_size, badge_size, + self.theme["colors"]["primary"]["dark_blue"]) + add_text_box(slide, left, y + (row_h - badge_size) / 2, + badge_size, badge_size, + str(step.get("numero", i + 1)), + self._font_body, 11, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + + # Titre + add_text_box(slide, left + badge_size + 0.25, + y + (row_h - badge_size) / 2, + width * 0.35, badge_size, + step.get("titre", ""), + self._font_body, 13, + bold=True, + color=self.theme["colors"]["primary"]["dark_blue"]) + + # Description + if step.get("description"): + add_text_box(slide, left + badge_size + 0.25 + width * 0.36, + y + (row_h - badge_size) / 2, + width - badge_size - 0.25 - width * 0.36, + badge_size, + step["description"], + self._font_body, 10, + color=self.theme["colors"]["text"]["body"]) + + # Séparateur + if i < len(steps) - 1: + add_line(slide, left, y + row_h, + left + width, y + row_h, + "#e8e2d6", 0.02) + + # C20 — chevron_step ─────────────────────────────────────────────────── + + def _render_chevrons(self, slide, zone, slide_data, + left, top, width, height): + """Chevrons horizontaux de process.""" + phases = slide_data.get("phases", []) + if not phases: + return + + n = len(phases) + tip = 0.40 # largeur de la pointe + chev_h = 1.00 + total_w = width - 0.50 + chev_w = total_w / n + bullets_top = top + chev_h + 0.30 + + for i, phase in enumerate(phases): + x = left + i * chev_w + is_active = phase.get("actif", False) + is_last = (i == n - 1) + + fill = (self.theme["colors"]["primary"]["dark_blue"] + if is_active else + self.theme["colors"]["primary"]["hague_grey"]) + + # Rectangle du chevron + add_rect(slide, x, top, chev_w - 0.10, chev_h, fill) + # Texte + add_text_box(slide, x + 0.20, top + 0.20, + chev_w - 0.60, 0.60, + phase.get("label", ""), + self._font_display, 13, + bold=True, + color=self.theme["colors"]["primary"]["rose"] + if is_active else "#ffffff", + align=PP_ALIGN.CENTER) + + # Durée sous le chevron + if phase.get("duree"): + add_text_box(slide, x, top + chev_h + 0.05, + chev_w, 0.30, + phase["duree"], + self._font_body, 8, + color=self.theme["colors"]["text"]["body"], + align=PP_ALIGN.CENTER) + + # Bullets sous la phase + if phase.get("bullets"): + for j, bullet in enumerate(phase["bullets"]): + add_text_box(slide, x + 0.15, + bullets_top + j * 0.55, + chev_w - 0.30, 0.50, + "• " + bullet, + self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # C21 — gantt_bar ────────────────────────────────────────────────────── + + def _render_gantt(self, slide, zone, slide_data, + left, top, width, height): + """Gantt simplifié.""" + period = slide_data.get("period", {}) + workstreams = slide_data.get("workstreams", []) + if not workstreams: + return + + label_w = zone.get("label_col_width_cm", 5.50) + header_h = zone.get("header_height_cm", 0.60) + stream_h = zone.get("workstream_height_cm", 2.80) + timeline_w = width - label_w + + # Parse période + def parse_ym(s): + parts = str(s).split("-") + return int(parts[0]) * 12 + int(parts[1]) if len(parts) == 2 else 0 + + p_start = parse_ym(period.get("start", "2026-01")) + p_end = parse_ym(period.get("end", "2026-12")) + total_months = max(p_end - p_start + 1, 1) + + # Header mois + import calendar + months_short = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + add_rect(slide, left + label_w, top, timeline_w, header_h, + self.theme["colors"]["backgrounds"]["content_area"]) + + for m in range(total_months): + mx = left + label_w + (m / total_months) * timeline_w + mw = timeline_w / total_months + ym = p_start + m + month_name = months_short[(ym - 1) % 12] + add_text_box(slide, mx, top + 0.08, mw, 0.40, + month_name, self._font_body, 7, + color="#48545a", align=PP_ALIGN.CENTER) + + colors = self.theme["colors"]["cycle"] + + for i, ws in enumerate(workstreams): + y = top + header_h + i * stream_h + color = colors[i % len(colors)] + + # Label workstream (optionnel) + if ws.get("label"): + add_rect(slide, left, y, label_w - 0.20, stream_h - 0.10, + self.theme["colors"]["backgrounds"]["content_area"]) + add_text_box(slide, left + 0.15, y + stream_h / 2 - 0.20, + label_w - 0.40, 0.40, + ws["label"], self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + else: + add_rect(slide, left, y, label_w - 0.20, stream_h - 0.10, + "#e8e2d6") + + # Barres de tâches + for task in ws.get("tasks", []): + t_start = parse_ym(task.get("start", period.get("start"))) + t_end = parse_ym(task.get("end", period.get("end"))) + row = task.get("row", 1) + + offset_x = ((t_start - p_start) / total_months) * timeline_w + bar_w = max(((t_end - t_start + 1) / total_months) * timeline_w, 0.30) + bar_y = y + (row - 1) * (stream_h / 2) + 0.25 + bar_h = stream_h / 2 - 0.35 + + tc = self._r(task.get("couleur") or color) + add_rect(slide, left + label_w + offset_x, bar_y, + bar_w, bar_h, tc) + + # C22 — timeline_milestone ───────────────────────────────────────────── + + def _render_timeline(self, slide, zone, slide_data, + left, top, width, height): + """Timeline horizontale (yearly ou phases).""" + milestones = slide_data.get("milestones", []) + if not milestones: + # phases_timeline variant + self._render_phases_timeline(slide, zone, slide_data, left, top, width, height) + return + + n = len(milestones) + axis_y = top + height / 2 + spacing = width / (n + 1) + + # Axe + add_line(slide, left, axis_y, left + width, axis_y, "#48545a", 0.04) + # Flèche → + add_text_box(slide, left + width - 0.30, axis_y - 0.20, + 0.40, 0.40, "→", self._font_body, 10, color="#48545a") + + colors = self.theme["colors"] + for i, m in enumerate(milestones): + mx = left + (i + 1) * spacing + is_active = m.get("actif", False) + circle_color = (colors["primary"]["rose"] if is_active + else colors["primary"]["dark_blue"]) + year_color = (colors["primary"]["rose"] if is_active + else colors["primary"]["bright_blue"]) + + # Cercle sur l'axe + r = 0.18 + shape = slide.shapes.add_shape(9, + cm(mx - r), cm(axis_y - r), cm(r * 2), cm(r * 2)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(circle_color) + shape.line.fill.background() + + # Année au-dessus + add_text_box(slide, mx - 1.0, axis_y - 1.20, + 2.0, 0.50, str(m.get("annee", "")), + self._font_display, 13, + bold=True, color=year_color, + align=PP_ALIGN.CENTER) + + # Label + add_text_box(slide, mx - 1.5, axis_y + 0.30, + 3.0, 0.40, m.get("label", ""), + self._font_body, 9, + bold=True if is_active else False, + color=colors["primary"]["dark_blue"], + align=PP_ALIGN.CENTER) + + # Description + if m.get("description"): + add_text_box(slide, mx - 1.5, axis_y + 0.75, + 3.0, 0.70, m["description"], + self._font_body, 8, + color=colors["text"]["body"], + align=PP_ALIGN.CENTER) + + def _render_phases_timeline(self, slide, zone, slide_data, + left, top, width, height): + """Phases horizontales contiguës (L24).""" + phases = slide_data.get("phases", []) + if not phases: + return + + n = len(phases) + phase_h = 0.65 + period_h = 0.40 + colors = self.theme["colors"]["cycle"] + + # Largeur proportionnelle ou égale + phase_w = width / n + + for i, phase in enumerate(phases): + x = left + i * phase_w + color = colors[i % len(colors)] + + add_rect(slide, x, top, phase_w - 0.10, phase_h, color) + add_text_box(slide, x + 0.10, top + 0.10, + phase_w - 0.20, phase_h - 0.15, + phase.get("label", ""), + self._font_body, 8, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + + if phase.get("periode"): + add_text_box(slide, x, top + phase_h + 0.05, + phase_w, period_h, + phase["periode"], + self._font_body, 7, + color=self._r(color), + align=PP_ALIGN.CENTER) + + items = phase.get("items", []) + for j, item in enumerate(items): + add_text_box(slide, x + 0.10, + top + phase_h + period_h + 0.20 + j * 0.55, + phase_w - 0.20, 0.50, + "• " + item, + self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # C23 — org_node ─────────────────────────────────────────────────────── + + def _render_org_chart(self, slide, zone, slide_data, + left, top, width, height): + """Organigramme hiérarchique top-down.""" + root = slide_data.get("root", {}) + if not root: + return + + colors_by_level = [ + self.theme["colors"]["primary"]["dark_blue"], + self.theme["colors"]["secondary"]["barley_green"], + self.theme["colors"]["secondary"]["cool_blue"], + self.theme["colors"]["primary"]["bright_warm"], + ] + text_by_level = ["#ffffff", "#ffffff", "#000a32", "#000a32"] + + node_h = 0.65 + level_gap = 1.20 + + def draw_tree(node, level, x_center, y): + color = colors_by_level[min(level, len(colors_by_level) - 1)] + txt_color = text_by_level[min(level, len(text_by_level) - 1)] + node_w = max(3.0 - level * 0.3, 2.0) + + nx = x_center - node_w / 2 + add_rect(slide, nx, y, node_w, node_h, color) + add_text_box(slide, nx + 0.10, y + 0.12, + node_w - 0.20, node_h - 0.15, + node.get("label", ""), + self._font_body, 8, + bold=True, color=txt_color, + align=PP_ALIGN.CENTER) + + children = node.get("children", []) + if not children: + return + + nc = len(children) + child_span = min(width / max(nc, 1), 6.0) + children_total_w = child_span * nc + child_start_x = x_center - children_total_w / 2 + child_span / 2 + + child_y = y + node_h + level_gap + + # Ligne verticale descendante + add_line(slide, x_center, y + node_h, + x_center, y + node_h + level_gap / 2, + "#48545a", 0.03) + + # Ligne horizontale + add_line(slide, + child_start_x, y + node_h + level_gap / 2, + child_start_x + children_total_w - child_span, + y + node_h + level_gap / 2, + "#48545a", 0.03) + + for i, child in enumerate(children): + cx = child_start_x + i * child_span + add_line(slide, cx, y + node_h + level_gap / 2, + cx, child_y, "#48545a", 0.03) + draw_tree(child, level + 1, cx, child_y) + + draw_tree(root, 0, left + width / 2, top) + + # C24 — raci_cell ────────────────────────────────────────────────────── + + def _render_raci(self, slide, zone, slide_data, + left, top, width, height): + """Matrice RACI.""" + roles = slide_data.get("roles", []) + tasks = slide_data.get("tasks", []) + if not roles or not tasks: + return + + task_col_w = zone.get("task_col_width_cm", 8.00) + header_h = 0.65 + role_col_w = (width - task_col_w) / max(len(roles), 1) + row_h = min((height - header_h) / max(len(tasks), 1), 0.80) + + raci_colors = { + "R": self.theme["semantic"]["responsible"], + "A": self.theme["semantic"]["accountable"], + "C": self.theme["semantic"]["consulted"], + "I": self.theme["semantic"]["informed"], + } + + # Header + add_rect(slide, left, top, task_col_w, header_h, + self.theme["colors"]["backgrounds"]["content_area"]) + for j, role in enumerate(roles): + x = left + task_col_w + j * role_col_w + add_rect(slide, x, top, role_col_w, header_h, + self.theme["colors"]["primary"]["dark_blue"]) + add_text_box(slide, x, top + 0.10, role_col_w, 0.45, + role, self._font_body, 9, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + + for i, task in enumerate(tasks): + y = top + header_h + i * row_h + bg = ("#ffffff" if i % 2 == 0 + else self.theme["colors"]["backgrounds"]["content_area"]) + + add_rect(slide, left, y, task_col_w, row_h, bg) + add_text_box(slide, left + 0.20, y + 0.12, + task_col_w - 0.30, row_h - 0.15, + task.get("label", ""), + self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + raci_vals = task.get("raci", []) + for j in range(len(roles)): + x = left + task_col_w + j * role_col_w + add_rect(slide, x, y, role_col_w, row_h, bg) + + val = raci_vals[j] if j < len(raci_vals) else "" + if val in raci_colors: + r_d = 0.35 + r_x = x + role_col_w / 2 - r_d / 2 + r_y = y + row_h / 2 - r_d / 2 + opacity = 1.0 if val in ("R", "A", "C") else 0.45 + shape = slide.shapes.add_shape(9, + cm(r_x), cm(r_y), cm(r_d), cm(r_d)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb(raci_colors[val]) + shape.line.fill.background() + + add_text_box(slide, r_x, r_y + 0.04, + r_d, r_d - 0.08, + val, self._font_body, 8, + bold=True, + color="#ffffff" if val in ("R", "A") else "#000a32", + align=PP_ALIGN.CENTER) + + add_line(slide, left, y + row_h, left + width, y + row_h, "#e8e2d6", 0.02) + + # C25 — decision_node ────────────────────────────────────────────────── + + def _render_decision_tree(self, slide, zone, slide_data, + left, top, width, height): + """Arbre de décision YES/NO.""" + question = slide_data.get("question", "") + branches = slide_data.get("branches", {}) + + # Question centrale + q_w, q_h = 7.0, 2.80 + q_x = left + 0.50 + q_y = top + height / 2 - q_h / 2 + + add_rect(slide, q_x, q_y, q_w, q_h, + self.theme["colors"]["backgrounds"]["content_area"], + "#48545a", 0.03) + add_text_box(slide, q_x + 0.30, q_y + 0.30, + q_w - 0.60, q_h - 0.60, + question, self._font_body, 10, + color=self.theme["colors"]["primary"]["dark_blue"]) + + branch_configs = [ + ("yes", "YES", top + height * 0.20), + ("no", "NO", top + height * 0.65), + ] + colors_branch = { + "yes": self.theme["colors"]["primary"]["bright_warm"], + "no": self.theme["colors"]["primary"]["rose"], + } + + for key, label, branch_y in branch_configs: + branch = branches.get(key, {}) + if not branch: + continue + + # Connecteur + label YES/NO + add_line(slide, q_x + q_w, q_y + q_h / 2, + left + q_w + 2.0, branch_y + 1.0, + "#48545a", 0.03) + add_text_box(slide, q_x + q_w + 0.20, + (q_y + q_h / 2 + branch_y + 1.0) / 2 - 0.15, + 0.80, 0.30, label, + self._font_body, 8, bold=True, + color="#48545a") + + # Nœud branche + b_x = left + q_w + 2.0 + b_w, b_h = 6.0, 2.20 + branch_color = colors_branch.get(key, "#d9d9c4") + add_rect(slide, b_x, branch_y, b_w, b_h, + branch_color, "#48545a", 0.03) + add_text_box(slide, b_x + 0.25, branch_y + 0.25, + b_w - 0.50, b_h - 0.50, + branch.get("label", ""), + self._font_body, 10, + color=self.theme["colors"]["primary"]["dark_blue"]) + + # Options terminales + options = branch.get("options", []) + opt_x = b_x + b_w + 0.80 + opt_w = left + width - opt_x - 0.20 + for k, opt in enumerate(options[:2]): + opt_y = branch_y + k * 1.20 + add_line(slide, b_x + b_w, branch_y + b_h / 2, + opt_x, opt_y + 0.40, "#48545a", 0.02) + add_rect(slide, opt_x, opt_y, opt_w, 1.0, + self.theme["colors"]["backgrounds"]["content_area"], + "#e8e2d6", 0.02) + add_text_box(slide, opt_x + 0.20, opt_y + 0.15, + opt_w - 0.40, 0.70, + opt, self._font_body, 9, + color=self.theme["colors"]["primary"]["dark_blue"]) + + # C26 — recommendation_sidebar ───────────────────────────────────────── + + def _render_recommendation_sidebar(self, slide, zone, slide_data, + left, top, width, height): + """Sidebar jaune + corps de la recommandation.""" + numero = slide_data.get("numero", 1) + titre = slide_data.get("titre", "") + resume = slide_data.get("resume", "") + cta = slide_data.get("cta", "") + headline = slide_data.get("headline", "") + bullets = slide_data.get("bullets", []) + + # Sidebar fond jaune + sidebar_w = width # width = 8.0 cm (défini dans layouts.yaml) + add_rect(slide, left, top, sidebar_w, height, + self.theme["colors"]["backgrounds"]["highlight_box"]) + + # Cercle numéro + r = 0.55 + cx = left + sidebar_w / 2 + shape = slide.shapes.add_shape(9, + cm(cx - r), cm(top + 1.0), cm(r * 2), cm(r * 2)) + shape.fill.solid() + shape.fill.fore_color.rgb = hex_to_rgb( + self.theme["colors"]["primary"]["dark_blue"]) + shape.line.fill.background() + add_text_box(slide, cx - r, top + 1.0, r * 2, r * 2, + str(numero), self._font_display, 18, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + + # Titre sidebar + add_text_box(slide, left + 0.30, top + 2.30, + sidebar_w - 0.60, 1.50, + titre, self._font_display, 16, + bold=True, + color=self.theme["colors"]["primary"]["dark_blue"], + align=PP_ALIGN.CENTER) + + # Résumé + if resume: + add_text_box(slide, left + 0.30, top + 4.0, + sidebar_w - 0.60, 3.0, + resume, self._font_body, 9, + color=self.theme["colors"]["text"]["body"]) + + # CTA + if cta: + add_rect(slide, left + 0.30, top + height - 2.0, + sidebar_w - 0.60, 0.80, + self.theme["colors"]["primary"]["dark_blue"]) + add_text_box(slide, left + 0.30, top + height - 2.0, + sidebar_w - 0.60, 0.80, + cta, self._font_body, 9, + bold=True, color="#ffffff", + align=PP_ALIGN.CENTER) + + # Contenu principal à droite de la sidebar + content_left = left + sidebar_w + 0.50 + content_w = 33.87 - content_left - 0.50 + + # Header band + if headline: + add_rect(slide, content_left, top + 0.80, + content_w, 1.10, + self.theme["colors"]["primary"]["dark_blue"]) + add_text_box(slide, content_left + 0.30, top + 1.0, + content_w - 0.60, 0.70, + headline, self._font_body, 11, + bold=True, color="#ffffff") + + # Bullets + if bullets: + zone_fake = {"id": "main_content", "component": "C06", + "width_cm": content_w} + slide_fake = {"bullets": bullets} + self._render_bullet_list(slide, zone_fake, slide_fake, + content_left, top + 2.20, + content_w, height - 2.50) + + +# ───────────────────────────────────────────────────────────────────────────── +# CLI +# ───────────────────────────────────────────────────────────────────────────── + +def main(): + import argparse + parser = argparse.ArgumentParser( + description="Sliding render_engine — JSON → PPTX Pernod Ricard") + parser.add_argument("json_file", + help="Fichier JSON de la présentation (sortie Agent 3)") + parser.add_argument("output", + help="Chemin du fichier PPTX à générer") + parser.add_argument("--theme", + default="theme.yaml", + help="Chemin vers theme.yaml (défaut: ./theme.yaml)") + parser.add_argument("--components", + default="components.yaml", + help="Chemin vers components.yaml") + parser.add_argument("--layouts", + default="layouts.yaml", + help="Chemin vers layouts.yaml") + args = parser.parse_args() + + if not os.path.exists(args.json_file): + print(f"✗ Fichier JSON introuvable : {args.json_file}") + sys.exit(1) + for f in [args.theme, args.components, args.layouts]: + if not os.path.exists(f): + print(f"✗ Fichier YAML introuvable : {f}") + sys.exit(1) + + engine = RenderEngine(args.theme, args.components, args.layouts) + with open(args.json_file, encoding="utf-8") as f: + raw = f.read().strip() + if not raw: + print(f"\u2717 Fichier d\'entr\u00e9e vide : {args.json_file}") + sys.exit(1) + # Accepte YAML ou JSON indiff\u00e9remment + try: + import yaml as _yaml + json_data = _yaml.safe_load(raw) + except Exception: + json_data = json.loads(raw) + engine.render(json_data, args.output) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/archive/v1_pipeline/theme.yaml b/archive/v1_pipeline/theme.yaml new file mode 100644 index 0000000..565259f --- /dev/null +++ b/archive/v1_pipeline/theme.yaml @@ -0,0 +1,329 @@ +meta: + version: '1.0' + date: '2026-05-13' + auteur: Bastien Gourdon + description: Charte graphique stable PR — ne pas modifier sans validation +slide: + width_cm: 33.87 + height_cm: 19.05 + width_emu: 12192000 + height_emu: 6858000 +colors: + primary: + dark_blue: '#000a32' + mid_blue: '#023466' + bright_blue: '#7fa5d0' + hague_grey: '#48545a' + rose: '#ff9166' + bright_warm: '#d9d9c4' + secondary: + bottle_green: '#536359' + barley_green: '#80bf75' + grape_green: '#c2d16b' + pot_still_copper: '#cd7f3c' + maize_yellow: '#ffcf0f' + wheat_yellow: '#fff0ba' + cool_blue: '#bad9ff' + grape_burgundy: '#872837' + dark_warm: '#8f8269' + mid_warm: '#d1b580' + backgrounds: + slide_default: '#ffffff' + slide_dark: '#000a32' + slide_warm: '#d9d9c4' + content_area: '#f5f1ea' + highlight_box: '#fff0ba' + text: + on_white: '#000a32' + on_dark: '#ffffff' + body: '#48545a' + subtitle: '#7fa5d0' + subtitle_dark: '#7fa5d0' + accent: '#ff9166' + muted: '#9a9a9a' + caption: '#6b6b6b' + cycle: + - '#000a32' + - '#48545a' + - '#7fa5d0' + - '#80bf75' + - '#ff9166' + - '#ffcf0f' + - '#872837' + - '#bad9ff' +typography: + display: + family: Cormorant Garamond + fallback: Georgia + weight_normal: 500 + weight_bold: 700 + style_default: normal + style_italic: italic + body: + family: Inter + fallback: Calibri + weight_light: 300 + weight_normal: 400 + weight_medium: 500 + weight_semibold: 600 + weight_bold: 700 + sizes: + cover_title: 40 + cover_subtitle: 20 + section_number: 64 + section_title: 32 + slide_title: 24 + slide_subtitle: 14 + heading_1: 18 + heading_2: 14 + body: 11 + body_small: 9 + bullet_l1: 11 + bullet_l2: 10 + bullet_l3: 9 + kpi_value: 32 + big_stat: 72 + tag: 8 + footer: 8 + logo_text: 9 + eyebrow: 9 + line_spacing: + tight: 1.0 + normal: 1.15 + loose: 1.3 +spacing: + margins: + top: 1.2 + bottom: 1.5 + left: 1.5 + right: 1.5 + title_area: + top: 0.5 + left: 1.8 + height: 2.2 + content_area: + top: 2.8 + left: 1.5 + right: 1.5 + bottom: 1.5 + gap: + between_columns: 0.4 + between_items: 0.25 + between_blocks: 0.5 + padding_box: 0.3 +signature: + accent_bar: + color: '#ff9166' + width_cm: 0.15 + position_left_cm: 1.5 + visible_on: + - default_bullets + - two_cols_text + - executive_summary + - kpi_grid + - big_stat + - comparison_table + - chart_callout + - benchmark + - matrix_2x2 + - pyramid + - circular_diagram + - from_to + - boxes_grid + - numbered_steps + - process_arrow + - gantt_timeline + - yearly_timeline + - phases_timeline + - org_chart + - raci_table + - decision_tree + - recommendation_card + footer: + visible: true + height_cm: 0.7 + background: '#ffffff' + border_top_color: '#e8e2d6' + border_top_width: 0.03 + elements: + slide_number: + position: left + color: '#000a32' + font: Inter + size_pt: 8 + bold: false + separator: + color: '#7fa5d0' + width_cm: 0.03 + logo: + file: assets/logo_pr_sun.png + height_cm: 0.4 + position: center-left + brand_name: + text: Pernod Ricard + color: '#000a32' + font: Inter + size_pt: 8 + bold: false + tagline: + text: DATA GOVERNANCE DATA MANAGEMENT + color: '#48545a' + font: Inter + size_pt: 7 + bold: false + position: right + hidden_on: + - cover_split + - section_divider + - end_slide + - content_marker + logo_topbar: + file: assets/logo_pr_sun.png + position_left_cm: 1.5 + position_top_cm: 0.3 + height_cm: 0.5 + visible_on: + - default_bullets + - two_cols_text + - key_message + - executive_summary + - agenda + - kpi_grid + - big_stat + - comparison_table + - chart_callout + - benchmark + - matrix_2x2 + - pyramid + - circular_diagram + - from_to + - boxes_grid + - numbered_steps + - process_arrow + - gantt_timeline + - yearly_timeline + - phases_timeline + - org_chart + - raci_table + - decision_tree + - recommendation_card +bullets: + level_1: + marker: • + indent_cm: 0.5 + font_size: 11 + color: '#000a32' + space_before_pt: 4 + level_2: + marker: – + indent_cm: 1.0 + font_size: 10 + color: '#48545a' + space_before_pt: 2 + level_3: + marker: ▪ + indent_cm: 1.5 + font_size: 9 + color: '#48545a' + space_before_pt: 1 +shapes: + diagonal_split: + color_left: '#000a32' + color_right: '#023466' + angle_deg: 15 + section_circle: + fill: '#000a32' + text_color: '#ffffff' + diameter_cm: 2.8 + font: Cormorant Garamond + font_size_pt: 32 + font_style: normal + step_badge: + fill: '#000a32' + text_color: '#ffffff' + size_cm: 0.7 + font: Inter + font_size_pt: 11 + font_weight: 700 + chevron: + fill_default: '#48545a' + fill_active: '#000a32' + text_default: '#ffffff' + text_active: '#ff9166' + height_cm: 0.9 + tip_width_cm: 0.4 + phase_bar: + height_cm: 0.5 + font_size: 7 + font_weight: 700 + text_color: '#ffffff' + callout_box: + fill: '#fff0ba' + border_color: '#ffcf0f' + border_width: 0.05 + padding_cm: 0.3 + recommendation_sidebar: + fill: '#fff0ba' + width_cm: 3.5 +semantic: + responsible: '#80bf75' + accountable: '#80bf75' + consulted: '#bad9ff' + informed: '#bad9ff' + high: '#000a32' + medium: '#7fa5d0' + low: '#d9d9c4' + success: '#80bf75' + warning: '#ffcf0f' + danger: '#872837' + neutral: '#48545a' + from_color: '#48545a' + to_color: '#000a32' + arrow_color: '#cd7f3c' +icons: + source: assets/icons/ + format: png + fallback: text + default_size_cm: 0.6 +assets: + base_path: assets/ + logo_sun: assets/logo_pr_sun.png + logo_full: assets/logo_pr_full.png + fonts_path: assets/fonts/ + fonts: + - family: Cormorant Garamond + files: + regular: CormorantGaramond-Regular.ttf + medium: CormorantGaramond-Medium.ttf + semibold: CormorantGaramond-SemiBold.ttf + bold: CormorantGaramond-Bold.ttf + italic: CormorantGaramond-Italic.ttf + - family: Inter + files: + light: Inter-Light.ttf + regular: Inter-Regular.ttf + medium: Inter-Medium.ttf + semibold: Inter-SemiBold.ttf + bold: Inter-Bold.ttf +engine: + title_color_rule: + dark_backgrounds: + - '#000a32' + - '#023466' + - '#48545a' + - '#536359' + - '#872837' + light_backgrounds: + - '#ffffff' + - '#d9d9c4' + - '#f5f1ea' + - '#fff0ba' + font_embedding: + embed_attempt: true + fallback_display: Georgia + fallback_body: Calibri + emu_conversion: + cm_to_emu: 360000 + pt_to_emu: 12700 + slide_number_format: '{n}' + slide_number_start: 1 diff --git a/components_v2.yaml b/components_v2.yaml new file mode 100644 index 0000000..7255f59 --- /dev/null +++ b/components_v2.yaml @@ -0,0 +1,74 @@ +# ============================================================ +# COMPONENTS V2 — Design System "PR Editorial" +# ============================================================ +# Composants unifiés : un composant = une fonction du moteur. +# Tous les layouts assemblent ces briques — aucun style local. +# ============================================================ + +components: + + # C-CARD : carte standard (fond chaud, coins arrondis, ombre) + card: + fill: theme.backgrounds.card + corner_radius: 0.06 + shadow: theme.card_style.shadow + padding_cm: 0.9 + + # C-BADGE : rond numéroté (motif cercle) + badge: + shape: circle + fill: theme.primary.navy # surchargé par couleur de cycle si série + text_color: theme.text.on_dark + font: display + sizes: # diamètres en cm + s: 1.27 # légende + m: 1.73 # exec summary + l: 2.29 # numbered steps + xl: 3.30 # recommendation sidebar + + # C-HEADERBAND : bandeau d'en-tête de carte / colonne + header_band: + height_cm: 2.16 + font: body + size: 18 + bold: true + text_color: theme.text.on_dark + + # C-STAT : chiffre en grand + stat: + color: theme.accent.coral + font: display + bold: true + + # C-GHOST-NUMBER : numéro géant des dividers + ghost_number: + color: theme.primary.navy_light + font: display + size: 250 + + # C-SQUARE-MARK : puce carrée corail (default_bullets) + square_mark: + size_cm: 0.56 + fill: theme.accent.coral + + # C-ARROW : flèche de liaison (risques -> solutions) + arrow: + char: "→" + color: theme.accent.coral + size: 22 + + # C-DECOR-CIRCLES : cercles décoratifs hors-champ (cover / end) + decor_circles: + cover: + - {x: 25.9, y: -4.1, d: 13.2, color: theme.primary.navy_light} + - {x: 29.0, y: 9.7, d: 9.1, color: theme.accent.coral} + - {x: 23.9, y: 13.7, d: 3.6, color: theme.secondary.glacier} + end: + - {x: -4.6, y: 11.7, d: 11.7, color: theme.primary.navy_light} + - {x: 30.2, y: -3.3, d: 8.1, color: theme.accent.coral} + + # C-DASHLINE : ligne pointillée de connexion (timeline) + dash_line: + color: theme.text.muted + width_pt: 1.25 + dash: dash diff --git a/facilitator_v3.py b/facilitator_v3.py new file mode 100644 index 0000000..eab1f71 --- /dev/null +++ b/facilitator_v3.py @@ -0,0 +1,791 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +facilitator_v3.py — Sliding Pipeline v5 · Pernod Ricard +======================================================== +Orchestre les 3 agents Mistral + render_engine_v2, avec une +**organisation par projet**. + +Arborescence : + projets/ + / + inputs/ ← documents de contexte (PDF, txt, md, docx) fournis par Bastien + outputs/ ← Markdown, plan, YAML, PPTX, manifest générés + +Workflow : + Lancement → nouveau projet ou existant ? + → documents dans inputs/ à analyser ? + → brief + The Narrator (Agent 1) → Markdown [+ contexte documentaire injecté] + The Designer (Agent 2) → Plan [optionnel : mode express] + The Encoder (Agent 3) → YAML validé (contre layouts_v2) + render_engine_v2.py → PPTX + +Nouveautés v5 : + - Organisation par projet (projets//inputs|outputs) + - Sélection nouveau / existant au lancement + - Lecture des documents de contexte (inputs/) injectés au Narrator + - FIX : double 'output/output/' du manifest (séparation dir / nom de fichier) + +Conserve de la v4 : retry réseau, garde pagination, historique cumulatif, +retour Narrator complet, yaml sort_keys=False, mode --render, mode express, +manifest JSON, archivage Trilium optionnel. + +Variables .env : + MISTRAL_API_KEY (requis sauf mode --render) + NARRATOR_AGENT_ID / DESIGNER_AGENT_ID / ENCODER_AGENT_ID + PROJECTS_DIR (défaut : ./projets) + RENDER_ENGINE_PATH (défaut : render_engine_v2.py) + THEME_PATH / COMPONENTS_PATH / LAYOUTS_PATH + CONTEXT_MAX_CHARS (défaut : 12000 — plafond du contexte documentaire) + TRILIUM_API_URL / TRILIUM_API_KEY / TRILIUM_PROJET +""" + +import argparse +import json +import os +import re +import subprocess +import sys +import textwrap +import time +from datetime import datetime +from pathlib import Path +from typing import Optional + +import requests +import yaml +from dotenv import load_dotenv + +load_dotenv() + +# ───────────────────────────────────────────────────────────────────────────── +# CONFIGURATION +# ───────────────────────────────────────────────────────────────────────────── + +API_KEY = os.getenv("MISTRAL_API_KEY") +NARRATOR_ID = os.getenv("NARRATOR_AGENT_ID") +DESIGNER_ID = os.getenv("DESIGNER_AGENT_ID") +ENCODER_ID = os.getenv("ENCODER_AGENT_ID") +PROJECTS_DIR = Path(os.getenv("PROJECTS_DIR", "./projets")) +RENDER_ENGINE_PATH = os.getenv("RENDER_ENGINE_PATH", "render_engine_v2.py") +THEME_PATH = os.getenv("THEME_PATH", "theme_v2.yaml") +COMPONENTS_PATH = os.getenv("COMPONENTS_PATH", "components_v2.yaml") +LAYOUTS_PATH = os.getenv("LAYOUTS_PATH", "layouts_v2.yaml") +CONTEXT_MAX_CHARS = int(os.getenv("CONTEXT_MAX_CHARS", "12000")) +TRILIUM_API_URL = os.getenv("TRILIUM_API_URL", "") +TRILIUM_API_KEY = os.getenv("TRILIUM_API_KEY", "") +TRILIUM_PROJET = os.getenv("TRILIUM_PROJET", "SlidingAutomation") + +HEADERS = { + "Authorization": f"Bearer {API_KEY}", + "Content-Type": "application/json", +} + +VERSION = "5.0" +MAX_PAGES = 10 +MAX_RETRY = 3 + +PROJECTS_DIR.mkdir(parents=True, exist_ok=True) + + +# ───────────────────────────────────────────────────────────────────────────── +# AFFICHAGE +# ───────────────────────────────────────────────────────────────────────────── + +def banner(): + print("\n" + "=" * 62) + print(f" SLIDING PIPELINE v{VERSION} — Pernod Ricard (design system v2)") + print(f" {datetime.now().strftime('%d/%m/%Y %H:%M')}") + print("=" * 62 + "\n") + + +def section(title): print(f"\n{'-'*62}\n {title}\n{'-'*62}\n") +def info(msg): print(f" i {msg}") +def ok(msg): print(f" + {msg}") +def warn(msg): print(f" ! {msg}") + + +def ask(prompt: str) -> str: + try: + return input(f"\n {prompt} ").strip() + except (EOFError, KeyboardInterrupt): + print("\n Session interrompue.") + sys.exit(0) + + +def display_markdown(text: str, max_lines: int = 60): + lines = text.splitlines() + if len(lines) <= max_lines: + print(textwrap.indent(text, " ")) + else: + print(textwrap.indent("\n".join(lines[:max_lines]), " ")) + print(f"\n ... [{len(lines) - max_lines} lignes supplémentaires]") + + +def display_plan(text: str, max_lines: int = 80): + slides = [l for l in text.splitlines() if l.strip().startswith("SLIDE")] + if slides: + print(" Résumé du plan :\n") + for s in slides: + print(f" {s.strip()}") + print(f"\n ({len(slides)} slides au total)") + else: + display_markdown(text, max_lines) + + +def slugify(name: str) -> str: + s = re.sub(r"[^a-zA-Z0-9]+", "-", name.lower()).strip("-") + return s[:40] + + +# ───────────────────────────────────────────────────────────────────────────── +# GESTION DE PROJET (nouveau v5) +# ───────────────────────────────────────────────────────────────────────────── + +class Project: + """Encapsule un projet : dossiers inputs/ et outputs/.""" + + def __init__(self, name: str): + self.name = name + self.slug = slugify(name) + self.root = PROJECTS_DIR / self.slug + self.inputs = self.root / "inputs" + self.outputs = self.root / "outputs" + + def ensure(self): + self.inputs.mkdir(parents=True, exist_ok=True) + self.outputs.mkdir(parents=True, exist_ok=True) + + def exists(self) -> bool: + return self.root.is_dir() + + def out(self, suffix: str, ext: str) -> Path: + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + return self.outputs / f"{self.slug}_{ts}_{suffix}.{ext}" + + @staticmethod + def list_existing() -> list: + if not PROJECTS_DIR.is_dir(): + return [] + return sorted([p.name for p in PROJECTS_DIR.iterdir() if p.is_dir()]) + + +def select_project() -> Optional[Project]: + """Menu nouveau / existant au lancement.""" + existing = Project.list_existing() + print(" [1] Nouveau projet") + if existing: + print(" [2] Projet existant") + print(" [0] Quitter") + choix = ask("Votre choix :") + + if choix == "0": + return None + if choix == "1": + name = ask("Nom du nouveau projet :") + if not name: + warn("Nom vide — annulé.") + return None + proj = Project(name) + if proj.exists(): + warn(f"Le projet '{proj.slug}' existe déjà — il sera réutilisé.") + proj.ensure() + ok(f"Projet créé : {proj.root}") + info(f"Déposez vos documents de contexte dans : {proj.inputs}") + return proj + if choix == "2" and existing: + print() + for i, name in enumerate(existing, 1): + print(f" [{i}] {name}") + sel = ask("Numéro du projet :") + try: + name = existing[int(sel) - 1] + except (ValueError, IndexError): + warn("Sélection invalide.") + return None + proj = Project(name) + proj.ensure() + ok(f"Projet ouvert : {proj.root}") + return proj + warn("Choix invalide.") + return None + + +# ───────────────────────────────────────────────────────────────────────────── +# LECTURE DES DOCUMENTS DE CONTEXTE (nouveau v5) +# ───────────────────────────────────────────────────────────────────────────── + +def _read_pdf(path: Path) -> str: + try: + from pypdf import PdfReader + reader = PdfReader(str(path)) + return "\n".join((pg.extract_text() or "") for pg in reader.pages) + except Exception: + try: + import pdfplumber + with pdfplumber.open(str(path)) as pdf: + return "\n".join((p.extract_text() or "") for p in pdf.pages) + except Exception as e: + warn(f"PDF illisible ({path.name}) : {e}") + return "" + + +def _read_docx(path: Path) -> str: + try: + import docx + doc = docx.Document(str(path)) + return "\n".join(p.text for p in doc.paragraphs) + except Exception as e: + warn(f"DOCX illisible ({path.name}) : {e}") + return "" + + +def read_context_documents(proj: Project) -> str: + """Lit tous les documents de inputs/ et retourne un bloc de contexte.""" + if not proj.inputs.is_dir(): + return "" + files = [f for f in sorted(proj.inputs.iterdir()) + if f.is_file() and not f.name.startswith(".")] + if not files: + return "" + + print() + info(f"{len(files)} document(s) trouvé(s) dans inputs/ :") + for f in files: + print(f" - {f.name}") + rep = ask("Analyser ces documents comme contexte ? (O/n) :").lower() + if rep in ("n", "non", "no"): + return "" + + chunks = [] + for f in files: + ext = f.suffix.lower() + if ext == ".pdf": + txt = _read_pdf(f) + elif ext == ".docx": + txt = _read_docx(f) + elif ext in (".txt", ".md", ".markdown", ".csv"): + txt = f.read_text(encoding="utf-8", errors="replace") + else: + warn(f"Format non supporté, ignoré : {f.name}") + continue + txt = txt.strip() + if txt: + chunks.append(f"### Document : {f.name}\n{txt}") + ok(f"Lu : {f.name} ({len(txt)} caractères)") + + if not chunks: + return "" + context = "\n\n".join(chunks) + if len(context) > CONTEXT_MAX_CHARS: + warn(f"Contexte tronqué à {CONTEXT_MAX_CHARS} caractères " + f"(total {len(context)}).") + context = context[:CONTEXT_MAX_CHARS] + "\n[...contexte tronqué...]" + return context + + +# ───────────────────────────────────────────────────────────────────────────── +# MANIFEST DE SESSION +# ───────────────────────────────────────────────────────────────────────────── + +class Manifest: + def __init__(self, brief: str, proj: Project, has_context: bool): + self.data = { + "version": VERSION, + "demarrage": datetime.now().isoformat(timespec="seconds"), + "projet": proj.name, + "brief": brief, + "contexte_documentaire": has_context, + "etapes": [], + "fichiers": [], + } + self._t0 = time.time() + + def step(self, nom, **extra): + self.data["etapes"].append( + {"nom": nom, "duree_s": round(time.time() - self._t0, 1), **extra}) + self._t0 = time.time() + + def file(self, path: Path): + self.data["fichiers"].append(str(path)) + + def save(self, path: Path): + """path = chemin COMPLET du fichier manifest (fix double-output).""" + self.data["fin"] = datetime.now().isoformat(timespec="seconds") + path.write_text(json.dumps(self.data, ensure_ascii=False, indent=2), + encoding="utf-8") + ok(f"Manifest : {path}") + return path + + +# ───────────────────────────────────────────────────────────────────────────── +# APPEL AGENT — retry + pagination +# ───────────────────────────────────────────────────────────────────────────── + +def _post_with_retry(payload: dict) -> dict: + last_err = None + for attempt in range(1, MAX_RETRY + 1): + try: + resp = requests.post( + "https://api.mistral.ai/v1/agents/completions", + headers=HEADERS, json=payload, timeout=120) + if resp.status_code == 429: + wait = 2 ** attempt * 5 + warn(f"Rate limit (429) — attente {wait}s " + f"(tentative {attempt}/{MAX_RETRY})") + time.sleep(wait) + continue + resp.raise_for_status() + return resp.json() + except requests.RequestException as e: + last_err = e + wait = 2 ** attempt * 2 + warn(f"Erreur réseau : {e} — retry dans {wait}s " + f"(tentative {attempt}/{MAX_RETRY})") + time.sleep(wait) + raise RuntimeError(f"API Mistral injoignable après {MAX_RETRY} " + f"tentatives : {last_err}") + + +def call_agent(agent_id: str, messages: list) -> str: + full_response = "" + current = messages.copy() + for page in range(MAX_PAGES): + data = _post_with_retry({"agent_id": agent_id, "messages": current}) + content = data["choices"][0]["message"]["content"] + full_response += content + if "PAUSE" in content and "FIN —" not in content: + info(f"Pagination détectée (page {page + 1}) → continuation...") + current.append({"role": "assistant", "content": content}) + current.append({"role": "user", "content": "continue"}) + else: + return full_response + warn(f"Garde pagination atteinte ({MAX_PAGES} pages) — " + "sortie tronquée possible.") + return full_response + + +class AgentSession: + def __init__(self, agent_id: str): + self.agent_id = agent_id + self.messages: list = [] + + def start(self, initial: str) -> str: + self.messages = [{"role": "user", "content": initial}] + out = call_agent(self.agent_id, self.messages) + self.messages.append({"role": "assistant", "content": out}) + return out + + def feedback(self, fb: str) -> str: + self.messages.append({"role": "user", "content": fb}) + out = call_agent(self.agent_id, self.messages) + self.messages.append({"role": "assistant", "content": out}) + return out + + def reset(self, initial: str) -> str: + return self.start(initial) + + +# ───────────────────────────────────────────────────────────────────────────── +# VALIDATION YAML +# ───────────────────────────────────────────────────────────────────────────── + +def extract_yaml(raw: str) -> str: + match = re.search(r"```ya?ml\s*(.*?)```", raw, re.DOTALL | re.IGNORECASE) + content = match.group(1).strip() if match else raw.strip() + return re.sub(r"[─]+", "-", content) + + +def validate_yaml(raw: str, layouts: dict): + try: + data = yaml.safe_load(extract_yaml(raw)) + except yaml.YAMLError as e: + return False, f"Erreur de syntaxe YAML : {e}", None + if not isinstance(data, dict): + return False, "Le YAML doit être un dictionnaire à la racine.", None + slides = data.get("slides") + if not slides: + return False, "Clé 'slides' manquante ou vide.", None + + errors = [] + valid = set(layouts.keys()) + for i, slide in enumerate(slides): + pos = slide.get("position", i + 1) + layout = slide.get("layout", "") + if not layout: + errors.append(f"Slide {pos} : clé 'layout' manquante") + continue + if layout not in valid: + errors.append(f"Slide {pos} : layout '{layout}' inconnu " + f"(valides : {', '.join(sorted(valid))})") + continue + for field in layouts[layout].get("champs_requis", []): + if field not in slide or slide.get(field) in (None, "", []): + errors.append(f"Slide {pos} ({layout}) : champ requis " + f"'{field}' manquant ou vide") + if errors: + return False, "\n ".join(errors), data + return True, f"{len(slides)} slides valides.", data + + +def layouts_digest(layouts: dict) -> str: + lines = ["Layouts valides et champs requis (design system v2) :"] + for name, cfg in layouts.items(): + req = ", ".join(cfg.get("champs_requis", [])) + lines.append(f"- {name} : {req}") + return "\n".join(lines) + + +def save_text(content: str, path: Path) -> Path: + path.write_text(content, encoding="utf-8") + ok(f"Sauvegardé : {path}") + return path + + +def load_layouts() -> dict: + if not os.path.exists(LAYOUTS_PATH): + warn(f"{LAYOUTS_PATH} introuvable — validation désactivée") + return {} + with open(LAYOUTS_PATH, encoding="utf-8") as f: + return yaml.safe_load(f).get("layouts", {}) + + +# ───────────────────────────────────────────────────────────────────────────── +# ARCHIVAGE TRILIUM (non bloquant) +# ───────────────────────────────────────────────────────────────────────────── + +def archive_to_trilium(brief, pptx_path, manifest_path, proj): + if not (TRILIUM_API_URL and TRILIUM_API_KEY): + return + try: + detail = f"Projet : {proj.name} | Brief : {brief[:200]}" + if pptx_path: + detail += f" | PPTX : {pptx_path}" + resp = requests.post( + f"{TRILIUM_API_URL.rstrip('/')}/api/historique", + headers={"Authorization": TRILIUM_API_KEY, + "Content-Type": "application/json"}, + json={"projet": TRILIUM_PROJET, "type": "Fait etabli", + "enonce": f"Génération PPTX — projet {proj.name}", + "detail": detail}, + timeout=15) + ok("Génération archivée dans Trilium.") if resp.ok else \ + warn(f"Trilium : réponse {resp.status_code} (non bloquant)") + except requests.RequestException as e: + warn(f"Trilium injoignable ({e}) — archivage ignoré.") + + +# ───────────────────────────────────────────────────────────────────────────── +# ÉTAPE 1 — NARRATOR (avec contexte documentaire) +# ───────────────────────────────────────────────────────────────────────────── + +def build_narrator_input(brief: str, context: str) -> str: + if context: + return (f"{brief}\n\n---\nCONTEXTE DOCUMENTAIRE FOURNI " + f"(à utiliser pour enrichir et cadrer la présentation) :\n\n" + f"{context}") + return brief + + +def run_narrator(session, brief, context, proj, first_output=None): + section("ÉTAPE 1 — THE NARRATOR") + if first_output is None: + if context: + info("Génération du Markdown narratif (avec contexte documentaire)...") + else: + info("Génération du Markdown narratif...") + output = session.start(build_narrator_input(brief, context)) + ok(f"Markdown généré ({len(output)} caractères)") + else: + output = first_output + + while True: + print() + display_markdown(output) + print("\n [1] Valider → étape suivante") + print(" [2] Donner du feedback au Narrator") + print(" [3] Régénérer complètement") + print(" [4] Afficher le Markdown complet") + print(" [5] Sauvegarder le Markdown") + choix = ask("Votre choix :") + if choix == "1": + ok("Markdown approuvé.") + return output + elif choix == "2": + fb = ask("Votre feedback :") + if fb: + info("Envoi du feedback au Narrator...") + output = session.feedback(fb) + ok(f"Nouveau Markdown ({len(output)} caractères)") + elif choix == "3": + info("Régénération depuis le brief...") + output = session.reset(build_narrator_input(brief, context)) + ok(f"Nouveau Markdown ({len(output)} caractères)") + elif choix == "4": + print("\n" + textwrap.indent(output, " ")) + elif choix == "5": + save_text(output, proj.out("narrator_draft", "md")) + else: + warn("Choix invalide. Entrez 1 à 5.") + + +# ───────────────────────────────────────────────────────────────────────────── +# ÉTAPE 2 — DESIGNER +# ───────────────────────────────────────────────────────────────────────────── + +def run_designer(session, markdown, proj): + section("ÉTAPE 2 — THE DESIGNER") + info("Génération du plan de présentation...") + output = session.start(markdown) + ok(f"Plan généré ({len(output)} caractères)") + while True: + print() + display_plan(output) + print("\n [1] Valider → passer à l'Encoder") + print(" [2] Donner du feedback au Designer") + print(" [3] Afficher le plan complet") + print(" [4] Sauvegarder le plan") + print(" [0] Revenir au Narrator (modifier le Markdown)") + choix = ask("Votre choix :") + if choix == "1": + ok("Plan approuvé. Passage à l'Encoder...") + save_text(output, proj.out("designer", "txt")) + return output + elif choix == "2": + fb = ask("Votre feedback :") + if fb: + info("Envoi du feedback au Designer...") + output = session.feedback(fb) + ok(f"Nouveau plan ({len(output)} caractères)") + elif choix == "3": + print("\n" + textwrap.indent(output, " ")) + elif choix == "4": + save_text(output, proj.out("designer_draft", "txt")) + elif choix == "0": + return None + else: + warn("Choix invalide. Entrez 0 à 4.") + + +# ───────────────────────────────────────────────────────────────────────────── +# ÉTAPE 3 — ENCODER +# ───────────────────────────────────────────────────────────────────────────── + +def run_encoder(session, plan, layouts, proj): + section("ÉTAPE 3 — THE ENCODER") + info("Encodage du plan en YAML...") + digest = layouts_digest(layouts) if layouts else "" + initial = f"{plan}\n\n---\n{digest}" if digest else plan + output = session.start(initial) + ok(f"YAML brut reçu ({len(output)} caractères)") + + attempts, max_attempts = 0, 3 + while attempts < max_attempts: + is_valid, message, data = validate_yaml(output, layouts) + if is_valid: + ok(f"YAML valide : {message}") + path = save_text(extract_yaml(output), proj.out("input", "yaml")) + return extract_yaml(output), data, path, attempts + attempts += 1 + warn(f"YAML invalide (tentative {attempts}/{max_attempts}) :") + print(f" {message}\n") + if attempts >= max_attempts: + break + info("Correction automatique...") + output = session.feedback( + f"Le YAML que tu as généré contient des erreurs :\n\n{message}\n\n" + f"Corrige ces erreurs et renvoie le YAML COMPLET corrigé, " + f"sans aucun texte avant ou après.") + ok(f"YAML corrigé reçu ({len(output)} caractères)") + + print("\n [1] Accepter le YAML tel quel (avec erreurs)") + print(" [2] Donner un feedback manuel à l'Encoder") + print(" [0] Abandonner et revenir au Designer") + choix = ask("Votre choix :") + if choix == "1": + path = save_text(extract_yaml(output), + proj.out("input_unvalidated", "yaml")) + try: + data = yaml.safe_load(extract_yaml(output)) or {} + except Exception: + data = {} + return extract_yaml(output), data, path, attempts + elif choix == "2": + fb = ask("Votre feedback :") + output = session.feedback(fb) + path = save_text(extract_yaml(output), proj.out("input_manual", "yaml")) + try: + data = yaml.safe_load(extract_yaml(output)) or {} + except Exception: + data = {} + return extract_yaml(output), data, path, attempts + return None, None, None, attempts + + +# ───────────────────────────────────────────────────────────────────────────── +# ÉTAPE 4 — RENDER ENGINE +# ───────────────────────────────────────────────────────────────────────────── + +def run_render(yaml_path: Path) -> Optional[Path]: + section("ÉTAPE 4 — RENDER ENGINE V2") + if not Path(RENDER_ENGINE_PATH).exists(): + warn(f"{RENDER_ENGINE_PATH} introuvable.") + return None + for path in [THEME_PATH, COMPONENTS_PATH, LAYOUTS_PATH]: + if not os.path.exists(path): + warn(f"Fichier de config manquant : {path}") + return None + pptx_out = yaml_path.with_suffix(".pptx") + info("Lancement de render_engine_v2.py...") + info(f"Sortie : {pptx_out}") + result = subprocess.run( + [sys.executable, RENDER_ENGINE_PATH, str(yaml_path), str(pptx_out), + "--theme", THEME_PATH, "--components", COMPONENTS_PATH, + "--layouts", LAYOUTS_PATH], + capture_output=True, text=True) + if result.returncode == 0: + ok(result.stdout.strip() or f"PPTX généré : {pptx_out}") + return pptx_out + warn("Erreur dans render_engine_v2.py :") + print(textwrap.indent(result.stderr or result.stdout, " ")) + return None + + +# ───────────────────────────────────────────────────────────────────────────── +# BOUCLE PRINCIPALE +# ───────────────────────────────────────────────────────────────────────────── + +def run_pipeline(): + banner() + missing = [k for k, v in { + "MISTRAL_API_KEY": API_KEY, "NARRATOR_AGENT_ID": NARRATOR_ID, + "DESIGNER_AGENT_ID": DESIGNER_ID, "ENCODER_AGENT_ID": ENCODER_ID, + }.items() if not v] + if missing: + for m in missing: + warn(f"Variable .env manquante : {m}") + sys.exit(1) + + layouts = load_layouts() + if layouts: + ok(f"{LAYOUTS_PATH} chargé — {len(layouts)} layouts") + + # Sélection du projet (une fois par lancement) + section("PROJET") + proj = select_project() + if proj is None: + print(" Au revoir.") + return + + while True: + # Contexte documentaire (inputs/) + context = read_context_documents(proj) + + brief = ask("Votre brief (ou 'projet' pour changer, 'quit') :") + if brief.lower() in ("quit", "exit", "q"): + print(" Au revoir.") + break + if brief.lower() == "projet": + section("PROJET") + np = select_project() + if np: + proj = np + continue + if not brief: + continue + + express = ask("Mode express — sans Designer ? (o/N) :").lower() \ + in ("o", "oui", "y", "yes") + if express: + info("Mode express : Narrator → Encoder direct.") + + manifest = Manifest(brief, proj, bool(context)) + narrator = AgentSession(NARRATOR_ID) + markdown = run_narrator(narrator, brief, context, proj) + if not markdown: + continue + save_text(markdown, proj.out("narrator", "md")) + manifest.step("narrator", caracteres=len(markdown), + contexte=bool(context)) + + yaml_path = yaml_data = None + while True: + if express: + plan = markdown + else: + designer = AgentSession(DESIGNER_ID) + plan = run_designer(designer, markdown, proj) + if plan is None: + markdown = run_narrator(narrator, brief, context, proj, + first_output=markdown) + if not markdown: + break + save_text(markdown, proj.out("narrator", "md")) + continue + manifest.step("designer", caracteres=len(plan)) + + encoder = AgentSession(ENCODER_ID) + yaml_str, yaml_data, yaml_path, tries = run_encoder( + encoder, plan, layouts, proj) + manifest.step("encoder", tentatives_correction=tries) + if yaml_str is None: + if express: + warn("Échec Encoder en mode express.") + break + info("Retour au Designer...") + continue + break + + if yaml_path and yaml_data: + manifest.file(yaml_path) + pptx_path = run_render(yaml_path) + manifest.step("render", succes=bool(pptx_path)) + if pptx_path and pptx_path.exists(): + manifest.file(pptx_path) + section("PRÉSENTATION GÉNÉRÉE") + ok(f"Fichier PPTX : {pptx_path}") + ok(f"Taille : {pptx_path.stat().st_size/1024:.1f} Ko") + else: + warn("Le PPTX n'a pas pu être généré.") + info(f"Le YAML est dans : {proj.outputs}") + # FIX : chemin complet du manifest (plus de double output/) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + manifest_path = manifest.save( + proj.outputs / f"{proj.slug}_{ts}_manifest.json") + archive_to_trilium(brief, pptx_path, manifest_path, proj) + + print() + if ask("Nouvelle présentation ? (o/n) :").lower() \ + not in ("o", "oui", "y", "yes"): + print(" Au revoir.") + break + + +# ───────────────────────────────────────────────────────────────────────────── +# POINT D'ENTRÉE +# ───────────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="Sliding facilitator v3 — pipeline par projet + render v2") + parser.add_argument("--render", metavar="YAML", + help="Rendu direct d'un YAML existant, sans agents") + args = parser.parse_args() + + if args.render: + yaml_path = Path(args.render) + if not yaml_path.exists(): + warn(f"Fichier introuvable : {yaml_path}") + sys.exit(1) + layouts = load_layouts() + is_valid, message, _ = validate_yaml( + yaml_path.read_text(encoding="utf-8"), layouts) + (ok if is_valid else warn)(f"Validation : {message}") + sys.exit(0 if run_render(yaml_path) else 1) + + run_pipeline() + + +if __name__ == "__main__": + main() diff --git a/layouts_v2.yaml b/layouts_v2.yaml new file mode 100644 index 0000000..28a5096 --- /dev/null +++ b/layouts_v2.yaml @@ -0,0 +1,229 @@ +# ============================================================ +# LAYOUTS V2 — Design System "PR Editorial" +# ============================================================ +# CHANGEMENT MAJEUR vs v1 : +# - plus de top_cm / height_cm par zone : le moteur applique +# la grille (theme.grid) et centre verticalement chaque layout +# - chaque layout déclare uniquement ses paramètres spécifiques +# - noms de layouts et agent_hints CONSERVÉS (compat agents P3) +# ============================================================ + +layouts: + + cover_split: + id: L01 + famille: "Ouverture" + mode: dark # fond navy + motif cercles + agent_hint: > + Slide de couverture. titre = titre de la présentation, + sous_titre = tagline en une phrase. + champs: [titre, sous_titre] + champs_requis: [titre] + + executive_summary: + id: L05 + famille: "Synthèse" + mode: light + agent_hint: > + Synthèse SCR. Titre = So What. 3 cartes pleine largeur : + situation, complication, resolution (1-2 phrases chacune). + champs: [titre, situation, complication, resolution] + champs_requis: [titre, situation, complication, resolution] + labels: ["SITUATION", "COMPLICATION", "RÉSOLUTION"] + + section_divider: + id: L03 + famille: "Structure" + mode: dark # navy + numéro géant ghost + agent_hint: > + Transition de section. titre = nom de la section. + Le numéro est incrémenté automatiquement par le moteur. + champs: [titre] + champs_requis: [titre] + + big_stat: + id: L10 + famille: "Données" + mode: light + agent_hint: > + Un chiffre héro plein écran. valeur = le chiffre (ex "78%"), + description = ce qu'il signifie, source = référence. + champs: [titre, valeur, description, source] + champs_requis: [titre, valeur] + + two_cols_text: + id: L07 + famille: "Comparaison" + mode: light + agent_hint: > + Deux colonnes en cartes avec en-tête coloré. + left/right = {titre, bullets[]}. Gauche = navy, droite = corail. + champs: [titre, left, right] + champs_requis: [titre, left, right] + + kpi_grid: + id: L09 + famille: "Données" + mode: light + agent_hint: > + 2 à 6 cartes KPI. items = [{label, valeur, description}]. + valeur en grand corail, label en en-tête, description en source. + champs: [titre, items] + champs_requis: [titre, items] + + key_message: + id: L08 + famille: "Message" + mode: dark # citation sur navy + guillemet corail + agent_hint: > + Message clé en citation plein écran. + message = la phrase forte, detail = sous-texte optionnel. + champs: [message, detail] + champs_requis: [message] + + circular_diagram: + id: L17 + famille: "Concept" + mode: light + agent_hint: > + 3 à 6 valeurs en cercles + légende à droite. + segments = [{label, description, couleur?}]. + champs: [titre, segments] + champs_requis: [titre, segments] + + default_bullets: + id: L06 + famille: "Texte structuré" + mode: light + agent_hint: > + Liste de points clés. Si texte au format "Mot : explication", + le moteur met le mot en gras navy. Max 5 bullets. + bullets = [{niveau, texte}]. + champs: [titre, bullets] + champs_requis: [titre, bullets] + + numbered_steps: + id: L19 + famille: "Process" + mode: light + agent_hint: > + Étapes en cartes pleine largeur avec badge rond numéroté. + steps = [{numero, titre, description}]. + champs: [titre, steps] + champs_requis: [titre, steps] + + phases_timeline: + id: L23 + famille: "Planning" + mode: light + agent_hint: > + Phases reliées par une ligne pointillée. + phases = [{label, periode}]. Couleurs du cycle theme. + champs: [titre, phases] + champs_requis: [titre, phases] + + recommendation_card: + id: L28 + famille: "Acteurs & décision" + mode: panel # sidebar navy 30% + corps light + agent_hint: > + Carte de recommandation. Sidebar = numero + titre + cta. + Corps = headline + bullets. + champs: [numero, titre, cta, headline, bullets] + champs_requis: [titre, headline, bullets] + sidebar_width_cm: 9.9 # ~30% de 33.87 + + end_slide: + id: L30 + famille: "Clôture" + mode: dark + agent_hint: > + Slide de fin. message = phrase de conclusion. + champs: [message] + champs_requis: [message] + + from_to_pairs: + id: L31 + famille: "Transformation" + mode: light + agent_hint: > + Transformation avant/après en paires alignées. + pairs = [{from, to}]. label_from/label_to = en-têtes colonnes. + Max 5 paires. Idéal pour tangibiliser un changement. + champs: [titre, pairs, label_from, label_to] + champs_requis: [titre, pairs] + + gantt_timeline: + id: L32 + famille: "Planning" + mode: light + agent_hint: > + Gantt par workstreams. periods = liste de labels (ex ["Juin","Juil"]). + workstreams = [{label, tasks: [{label, start, end}]}]. + start/end = index dans periods (0-based). Max 3 workstreams, 4 tâches chacun. + champs: [titre, periods, workstreams] + champs_requis: [titre, periods, workstreams] + + yearly_timeline: + id: L33 + famille: "Historique" + mode: light + agent_hint: > + Frise chronologique horizontale. milestones = [{annee, label, actif?}]. + actif = true pour le jalon courant (corail). Labels alternent haut/bas. + Max 6 jalons. + champs: [titre, milestones] + champs_requis: [titre, milestones] + + comparison_table: + id: L34 + famille: "Comparaison" + mode: light + agent_hint: > + Tableau structuré multi-critères. headers = liste de colonnes (1ère = critères). + rows = [{label, values: []}] ou listes plates. Max 5 colonnes, 8 lignes. + champs: [titre, headers, rows] + champs_requis: [titre, headers, rows] + + raci_table: + id: L35 + famille: "Gouvernance" + mode: light + agent_hint: > + Matrice RACI. roles = liste de rôles. tasks = [{label, raci: [R/A/C/I]}]. + R=Responsable(corail), A=Autorité(navy), C=Consulté(slate), I=Informé(muted). + Max 4 rôles, 8 tâches. + champs: [titre, roles, tasks] + champs_requis: [titre, roles, tasks] + + process_arrow: + id: L36 + famille: "Process" + mode: light + agent_hint: > + Flux horizontal de 3 à 6 étapes. steps = [{titre, description}]. + Badges numérotés corail, couleurs cycle alternées. Max 6 étapes. + champs: [titre, steps] + champs_requis: [titre, steps] + + org_chart: + id: L37 + famille: "Organisation" + mode: light + agent_hint: > + Organigramme hiérarchique max 3 niveaux. + root = {label, children: [{label, children?: [{label}]}]}. + Max 4 enfants directs, 3 petits-enfants par enfant. + champs: [titre, root] + champs_requis: [titre, root] + + matrix_2x2: + id: L38 + famille: "Priorisation" + mode: light + agent_hint: > + Matrice effort/impact. axis_x/axis_y = {label, low, high}. + quadrants = {top_left, top_right, bottom_left, bottom_right}. + items = [{label, x, y}] avec x/y de 0 à 100. Max 8 items. + champs: [titre, axis_x, axis_y, quadrants, items] + champs_requis: [titre, items] diff --git a/prompt_injection_v2.py b/prompt_injection_v2.py new file mode 100644 index 0000000..7522421 --- /dev/null +++ b/prompt_injection_v2.py @@ -0,0 +1,436 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +prompt_injection_v2.py — Sliding Pipeline v2 · Pernod Ricard +============================================================= +Génère les blocs de contraintes injectés dans les prompts des agents, +à partir du design system v2 (layouts_v2.yaml). + +Produit : + 1. agent_constraints_v2.md — référence complète (debug/doc) + 2. prompt_the_designer_injected_v2.md — prompt Designer prêt pour Mistral Studio + 3. prompt_the_encoder_injected_v2.md — prompt Encoder prêt pour Mistral Studio + +Usage : + python3 prompt_injection_v2.py + python3 prompt_injection_v2.py --layouts layouts_v2.yaml \ + --designer prompt_the_designer_v2.md \ + --encoder prompt_the_encoder_v2.md +""" + +import argparse +import sys +from pathlib import Path + +import yaml + + +# ───────────────────────────────────────────────────────────────────────────── +# DONNÉES ÉDITORIALES v2 (savoirs métier — 21 layouts uniquement) +# ───────────────────────────────────────────────────────────────────────────── + +SEQUENCING_RULES = """### Règles de fluidité + +- Maximum 2 layouts texte consécutifs : `default_bullets`, `two_cols_text` +- Après 3 slides denses → intercaler une respiration : `big_stat`, `key_message`, `section_divider` +- Jamais 2 `section_divider` consécutifs +- Alterner les modes : un slide dark (`section_divider`, `key_message`) toutes les 3-5 slides claires donne le rythme "sandwich" du design system +- Le dernier slide de contenu avant `end_slide` doit être narratif (`recommendation_card` ou `key_message`) + +### Règles de choix de layout + +- 1 seul chiffre décisif → `big_stat` (jamais `kpi_grid` avec 1 item) +- 2 à 6 indicateurs chiffrés → `kpi_grid` +- Comparaison ou opposition (avant/après, attentes/réalité, risques/solutions) → `two_cols_text` +- 2 à 5 étapes séquentielles → `numbered_steps` +- Phases avec périodes temporelles (mois, trimestres) → `phases_timeline` +- 3 à 6 concepts ou valeurs formant un système → `circular_diagram` +- Recommandation unique et actionnable → `recommendation_card` +- Citation ou message à marteler seul → `key_message` +- Transformation avant/après → `from_to_pairs` (plus parlant que two_cols_text) +- Planning projet avec tâches datées → `gantt_timeline` +- Frise chronologique d'historique ou de jalons annuels → `yearly_timeline` +- Tableau comparatif multi-critères → `comparison_table` +- Responsabilités par rôle → `raci_table` +- Flux séquentiel de 3 à 6 étapes (sans dates) → `process_arrow` +- Organigramme hiérarchique → `org_chart` +- Priorisation effort/impact → `matrix_2x2` +- Si le contenu ne rentre dans aucun layout spécialisé → `default_bullets` + +### Règles de quantité + +- Présentation 20 min → 10 à 15 slides max +- Présentation 10 min → 6 à 10 slides max +- Fusionner si > 15 slides : regrouper les slides proches thématiquement +- `default_bullets` : 5 bullets maximum — privilégier le format "Mot-clé : explication" +- Un `###` du Markdown avec un seul chiffre fort → envisager `big_stat` séparé +- Un `##` du Markdown = 1 `section_divider` (sauf présentation < 6 slides)""" + + +NARRATIVE_PATTERNS = """### Pattern "Problem → Solution → Proof" +Adapté aux présentations de recommandation stratégique. +``` +cover_split +executive_summary (synthèse SCR dès le début) +section_divider ("Le problème") +big_stat (chiffre choc) +two_cols_text ou default_bullets +section_divider ("Notre réponse") +numbered_steps +kpi_grid (preuve chiffrée) +recommendation_card (ce qu'on demande) +end_slide +``` + +### Pattern "Roadmap Deck" +Adapté aux présentations de planification / lancement de projet. +``` +cover_split +executive_summary (où on va et pourquoi) +kpi_grid (état des lieux chiffré) +phases_timeline (le calendrier) +numbered_steps (comment on s'organise) +recommendation_card (décisions à prendre) +end_slide +``` + +### Pattern "Executive Briefing" +Adapté aux présentations courtes (< 10 slides) pour un CODIR. +``` +cover_split +executive_summary +key_message (le So What en 1 slide) +kpi_grid ou big_stat +recommendation_card +end_slide +``` + +### Pattern "Vision & Valeurs" +Adapté aux présentations de transformation ou de culture. +``` +cover_split +executive_summary +section_divider +circular_diagram (le système de valeurs/concepts) +default_bullets (le cadre, les principes) +key_message (la conviction) +recommendation_card +end_slide +``` + +### Règles d'assemblage des patterns + +- Les patterns sont des points de départ, pas des contraintes rigides +- Hybrider 2 patterns est possible si le contenu le justifie +- Toujours préserver : cover_split en premier, end_slide en dernier +- Les section_dividers sont optionnels pour les patterns courts (< 8 slides)""" + + +# ───────────────────────────────────────────────────────────────────────────── +# EXEMPLES YAML v2 PAR LAYOUT (champs canoniques render_engine_v2) +# ───────────────────────────────────────────────────────────────────────────── + +EXAMPLES = { + "cover_split": [ + 'titre: "Titre de la présentation"', + 'sous_titre: "Tagline en une phrase"', + ], + "executive_summary": [ + 'titre: "Le So What en titre affirmatif"', + 'situation: "État des lieux factuel..."', + 'complication: "Le problème ou la tension..."', + 'resolution: "La réponse proposée..."', + ], + "section_divider": [ + 'titre: "Nom de la section"', + "# le numéro de section est généré automatiquement", + ], + "big_stat": [ + 'titre: "Titre affirmatif"', + 'valeur: "78%"', + 'description: "ce que le chiffre signifie"', + 'source: "Référence — 2026"', + ], + "two_cols_text": [ + 'titre: "Titre affirmatif"', + "left:", + ' titre: "Titre colonne gauche"', + " bullets:", + ' - texte: "Premier point"', + ' - texte: "Deuxième point"', + "right:", + ' titre: "Titre colonne droite"', + " bullets:", + ' - texte: "Premier point"', + ], + "kpi_grid": [ + 'titre: "Titre affirmatif"', + "items:", + ' - label: "Indicateur 1"', + ' valeur: "+13%"', + ' description: "Source ou contexte de la valeur"', + ' - label: "Indicateur 2"', + ' valeur: "85%"', + ' description: "Source ou contexte"', + ], + "key_message": [ + 'message: "Le message clé en une phrase forte."', + 'detail: "Sous-texte optionnel en une ligne."', + ], + "circular_diagram": [ + 'titre: "Titre affirmatif"', + "segments:", + ' - label: "Concept 1"', + ' description: "Description courte"', + ' - label: "Concept 2"', + ' description: "Description courte"', + ' - label: "Concept 3"', + ' description: "Description courte"', + ], + "default_bullets": [ + 'titre: "Titre affirmatif"', + "bullets:", + ' - texte: "Mot-clé : explication du point"', + " niveau: 1", + ' - texte: "Autre mot-clé : explication"', + " niveau: 1", + ], + "numbered_steps": [ + 'titre: "Titre affirmatif"', + "steps:", + " - numero: 1", + ' titre: "Première étape"', + ' description: "Ce que ça implique concrètement"', + " - numero: 2", + ' titre: "Deuxième étape"', + ' description: "Ce que ça implique"', + ], + "phases_timeline": [ + 'titre: "Titre affirmatif"', + "phases:", + ' - label: "Mois 1-2"', + ' periode: "Ce qui se passe dans cette phase"', + ' - label: "Mois 3-8"', + ' periode: "Ce qui se passe"', + ], + "recommendation_card": [ + "numero: 1", + 'titre: "Nom court de la recommandation"', + 'headline: "TROIS DÉCISIONS À PRENDRE MAINTENANT"', + 'cta: "Décider en réunion du 30 juin"', + "bullets:", + ' - texte: "Première décision précise"', + ' - texte: "Deuxième décision précise"', + ], + "end_slide": [ + 'message: "Phrase de conclusion mémorable"', + ], + "from_to_pairs": [ + 'titre: "Titre affirmatif"', + 'label_from: "SITUATION ACTUELLE"', + 'label_to: "SITUATION CIBLE"', + "pairs:", + ' - from: "État de départ"', + ' to: "État cible"', + ' - from: "Autre point de départ"', + ' to: "Autre cible"', + ], + "gantt_timeline": [ + 'titre: "Titre affirmatif"', + 'periods: ["Juin", "Juil", "Août", "Sept"]', + "workstreams:", + ' - label: "Workstream 1"', + " tasks:", + ' - label: "Tâche A"', + " start: 0", + " end: 2", + ' - label: "Workstream 2"', + " tasks:", + ' - label: "Tâche B"', + " start: 1", + " end: 3", + ], + "yearly_timeline": [ + 'titre: "Titre affirmatif"', + "milestones:", + ' - annee: "2024"', + ' label: "Premier jalon"', + ' - annee: "2025"', + ' label: "Deuxième jalon"', + ' - annee: "2026"', + ' label: "Jalon courant"', + " actif: true", + ], + "comparison_table": [ + 'titre: "Titre affirmatif"', + 'headers: ["Critère", "Option A", "Option B"]', + "rows:", + ' - label: "Premier critère"', + ' values: ["Valeur A", "Valeur B"]', + ' - label: "Deuxième critère"', + ' values: ["Valeur A", "Valeur B"]', + ], + "raci_table": [ + 'titre: "Titre affirmatif"', + 'roles: ["Rôle 1", "Rôle 2", "Rôle 3"]', + "tasks:", + ' - label: "Première activité"', + ' raci: ["A", "R", "C"]', + ' - label: "Deuxième activité"', + ' raci: ["A", "I", "R"]', + ], + "process_arrow": [ + 'titre: "Titre affirmatif"', + "steps:", + ' - titre: "Étape 1"', + ' description: "Ce qui se passe"', + ' - titre: "Étape 2"', + ' description: "Ce qui se passe"', + ' - titre: "Étape 3"', + ' description: "Ce qui se passe"', + ], + "org_chart": [ + 'titre: "Titre affirmatif"', + "root:", + ' label: "Responsable racine"', + " children:", + ' - label: "Manager 1"', + " children:", + ' - label: "Équipe A"', + ' - label: "Manager 2"', + " children:", + ' - label: "Équipe B"', + ], + "matrix_2x2": [ + 'titre: "Titre affirmatif"', + "axis_x:", + ' label: "Effort"', + ' low: "Faible"', + ' high: "Élevé"', + "axis_y:", + ' label: "Impact"', + ' low: "Faible"', + ' high: "Élevé"', + "quadrants:", + ' top_left: "Gains rapides"', + ' top_right: "Projets stratégiques"', + ' bottom_left: "Déprioritiser"', + ' bottom_right: "À planifier"', + "items:", + ' - label: "Initiative A"', + " x: 30", + " y: 80", + ' - label: "Initiative B"', + " x: 70", + " y: 75", + ], +} + + +# ───────────────────────────────────────────────────────────────────────────── +# GÉNÉRATEURS DE BLOCS (adaptés à la structure layouts_v2) +# ───────────────────────────────────────────────────────────────────────────── + +def generate_layouts_catalogue(layouts: dict) -> str: + """Bloc LAYOUTS_CATALOGUE pour le Designer — depuis layouts_v2.""" + lines = [] + current_famille = None + for name, cfg in layouts.items(): + famille = cfg.get("famille", "") + if famille != current_famille: + lines.append(f"\n#### {famille}") + current_famille = famille + layout_id = cfg.get("id", "") + mode = cfg.get("mode", "light") + hint = " ".join(cfg.get("agent_hint", "").split()) + lines.append(f"\n`{name}` ({layout_id}) — mode {mode}") + lines.append(f" → {hint}") + return "\n".join(lines) + + +def generate_yaml_schemas(layouts: dict) -> str: + """Bloc YAML_SCHEMAS pour l'Encoder — champs requis/optionnels v2 + + exemple complet par layout.""" + lines = [] + for name, cfg in layouts.items(): + layout_id = cfg.get("id", "") + champs = cfg.get("champs", []) + requis = cfg.get("champs_requis", []) + optionnels = [c for c in champs if c not in requis] + + lines.append(f"\n### `{name}` ({layout_id})") + if requis: + lines.append(f"**Requis :** {', '.join(requis)}") + if optionnels: + lines.append(f"**Optionnels :** {', '.join(optionnels)}") + lines.append("**Exemple :**") + lines.append("```yaml") + lines.append(f"layout: {name}") + lines.extend(EXAMPLES.get(name, [])) + lines.append("```") + return "\n".join(lines) + + +# ───────────────────────────────────────────────────────────────────────────── +# GÉNÉRATION DES FICHIERS +# ───────────────────────────────────────────────────────────────────────────── + +def inject(template_path: Path, replacements: dict, out_path: Path): + content = template_path.read_text(encoding="utf-8") + for placeholder, value in replacements.items(): + if placeholder not in content: + print(f" ! Placeholder {placeholder} absent de {template_path}") + content = content.replace(placeholder, value) + out_path.write_text(content, encoding="utf-8") + print(f" + Généré : {out_path}") + + +def main(): + parser = argparse.ArgumentParser( + description="Injection des contraintes v2 dans les prompts agents") + parser.add_argument("--layouts", default="layouts_v2.yaml") + parser.add_argument("--designer", default="prompt_the_designer_v2.md") + parser.add_argument("--encoder", default="prompt_the_encoder_v2.md") + args = parser.parse_args() + + layouts_path = Path(args.layouts) + if not layouts_path.exists(): + print(f" ! {layouts_path} introuvable") + sys.exit(1) + + with open(layouts_path, encoding="utf-8") as f: + layouts = yaml.safe_load(f)["layouts"] + print(f" + {layouts_path} chargé — {len(layouts)} layouts") + + catalogue = generate_layouts_catalogue(layouts) + schemas = generate_yaml_schemas(layouts) + + # 1. Référence complète (debug / doc) + constraints = "\n\n---\n\n".join([ + "# AGENT CONSTRAINTS v2 — généré par prompt_injection_v2.py\n" + "# Design System « PR Editorial » — 21 layouts", + "## LAYOUTS_CATALOGUE\n" + catalogue, + "## SEQUENCING_RULES\n" + SEQUENCING_RULES, + "## NARRATIVE_PATTERNS\n" + NARRATIVE_PATTERNS, + "## YAML_SCHEMAS\n" + schemas, + ]) + Path("agent_constraints_v2.md").write_text(constraints, encoding="utf-8") + print(" + Généré : agent_constraints_v2.md") + + # 2. Designer injecté + inject(Path(args.designer), { + "{{LAYOUTS_CATALOGUE}}": catalogue, + "{{SEQUENCING_RULES}}": SEQUENCING_RULES, + "{{NARRATIVE_PATTERNS}}": NARRATIVE_PATTERNS, + }, Path("prompt_the_designer_injected_v2.md")) + + # 3. Encoder injecté + inject(Path(args.encoder), { + "{{YAML_SCHEMAS}}": schemas, + }, Path("prompt_the_encoder_injected_v2.md")) + + print("\n Fichiers *_injected_v2.md prêts à coller dans Mistral Studio.") + + +if __name__ == "__main__": + main() diff --git a/prompt_the_designer_injected_v2.md b/prompt_the_designer_injected_v2.md new file mode 100644 index 0000000..870053c --- /dev/null +++ b/prompt_the_designer_injected_v2.md @@ -0,0 +1,390 @@ +# THE DESIGNER +# Sliding Pipeline v2 — Pernod Ricard · Design System "PR Editorial" +# Mistral Large · Temperature 0.4 · Format : Texte + +## RÔLE + +Tu es The Designer. Tu reçois un Markdown narratif approuvé par Bastien et tu produis un **plan de présentation** : la liste ordonnée des slides avec le layout choisi et les données à y mettre. + +Tu prends des décisions **éditoriales et visuelles** : quel layout rend ce contenu le plus lisible ? L'enchaînement des slides est-il fluide ? Y a-t-il trop de slides texte consécutifs ? Un chiffre mérite-t-il son propre slide ? + +Tu ne produis pas de YAML. Tu ne remplis pas de schéma. Tu produis un **plan texte structuré**, lisible par un humain et précis pour être encodé par l'agent suivant. + +--- + +## CE QUE TU REÇOIS + +Un Markdown avec : +- `#` = titre de la présentation +- `##` = sections (parties) +- `###` = slides individuels avec leur contenu brut + +--- + +## CE QUE TU PRODUIS + +Un plan de présentation slide par slide, dans ce format exact : + +``` +SLIDE [N] — [NOM_LAYOUT] + Titre : "[titre affirmatif]" + [champ 1] : [valeur ou description du contenu] + [champ 2] : [valeur ou description du contenu] + → [Justification éditoriale en 1 ligne] +``` + +**Règles de format :** +- Chaque slide commence par `SLIDE N — NOM_LAYOUT` (N = numéro, NOM_LAYOUT = nom exact du layout) +- Les champs suivent immédiatement, indentés de 2 espaces +- Le `→` en fin de slide = justification de ton choix éditorial (obligatoire) +- Pas de YAML, pas d'accolades, pas de crochets JSON +- Les valeurs textuelles longues → résumées en 1-2 lignes, pas copiées intégralement + +--- + +## LE DESIGN SYSTEM "PR EDITORIAL" (v2) + +La présentation alterne slides sombres (navy) et slides clairs — structure "sandwich" : +- **Dark** : cover_split, section_divider, key_message, end_slide — rythment le deck +- **Light** : tout le reste — le contenu + +Chaque slide de contenu est centré verticalement par le moteur de rendu : tu n'as +aucune position à indiquer, uniquement le contenu. Les titres portent l'insight +(le "So What"), jamais un simple label. + +--- + +## LES 21 LAYOUTS DISPONIBLES + +[CE BLOC EST GÉNÉRÉ AUTOMATIQUEMENT PAR prompt_injection_v2.py] +[NE PAS MODIFIER MANUELLEMENT] + + +#### Ouverture + +`cover_split` (L01) — mode dark + → Slide de couverture. titre = titre de la présentation, sous_titre = tagline en une phrase. + +#### Synthèse + +`executive_summary` (L05) — mode light + → Synthèse SCR. Titre = So What. 3 cartes pleine largeur : situation, complication, resolution (1-2 phrases chacune). + +#### Structure + +`section_divider` (L03) — mode dark + → Transition de section. titre = nom de la section. Le numéro est incrémenté automatiquement par le moteur. + +#### Données + +`big_stat` (L10) — mode light + → Un chiffre héro plein écran. valeur = le chiffre (ex "78%"), description = ce qu'il signifie, source = référence. + +#### Comparaison + +`two_cols_text` (L07) — mode light + → Deux colonnes en cartes avec en-tête coloré. left/right = {titre, bullets[]}. Gauche = navy, droite = corail. + +#### Données + +`kpi_grid` (L09) — mode light + → 2 à 6 cartes KPI. items = [{label, valeur, description}]. valeur en grand corail, label en en-tête, description en source. + +#### Message + +`key_message` (L08) — mode dark + → Message clé en citation plein écran. message = la phrase forte, detail = sous-texte optionnel. + +#### Concept + +`circular_diagram` (L17) — mode light + → 3 à 6 valeurs en cercles + légende à droite. segments = [{label, description, couleur?}]. + +#### Texte structuré + +`default_bullets` (L06) — mode light + → Liste de points clés. Si texte au format "Mot : explication", le moteur met le mot en gras navy. Max 5 bullets. bullets = [{niveau, texte}]. + +#### Process + +`numbered_steps` (L19) — mode light + → Étapes en cartes pleine largeur avec badge rond numéroté. steps = [{numero, titre, description}]. + +#### Planning + +`phases_timeline` (L23) — mode light + → Phases reliées par une ligne pointillée. phases = [{label, periode}]. Couleurs du cycle theme. + +#### Acteurs & décision + +`recommendation_card` (L28) — mode panel + → Carte de recommandation. Sidebar = numero + titre + cta. Corps = headline + bullets. + +#### Clôture + +`end_slide` (L30) — mode dark + → Slide de fin. message = phrase de conclusion. + +#### Transformation + +`from_to_pairs` (L31) — mode light + → Transformation avant/après en paires alignées. pairs = [{from, to}]. label_from/label_to = en-têtes colonnes. Max 5 paires. Idéal pour tangibiliser un changement. + +#### Planning + +`gantt_timeline` (L32) — mode light + → Gantt par workstreams. periods = liste de labels (ex ["Juin","Juil"]). workstreams = [{label, tasks: [{label, start, end}]}]. start/end = index dans periods (0-based). Max 3 workstreams, 4 tâches chacun. + +#### Historique + +`yearly_timeline` (L33) — mode light + → Frise chronologique horizontale. milestones = [{annee, label, actif?}]. actif = true pour le jalon courant (corail). Labels alternent haut/bas. Max 6 jalons. + +#### Comparaison + +`comparison_table` (L34) — mode light + → Tableau structuré multi-critères. headers = liste de colonnes (1ère = critères). rows = [{label, values: []}] ou listes plates. Max 5 colonnes, 8 lignes. + +#### Gouvernance + +`raci_table` (L35) — mode light + → Matrice RACI. roles = liste de rôles. tasks = [{label, raci: [R/A/C/I]}]. R=Responsable(corail), A=Autorité(navy), C=Consulté(slate), I=Informé(muted). Max 4 rôles, 8 tâches. + +#### Process + +`process_arrow` (L36) — mode light + → Flux horizontal de 3 à 6 étapes. steps = [{titre, description}]. Badges numérotés corail, couleurs cycle alternées. Max 6 étapes. + +#### Organisation + +`org_chart` (L37) — mode light + → Organigramme hiérarchique max 3 niveaux. root = {label, children: [{label, children?: [{label}]}]}. Max 4 enfants directs, 3 petits-enfants par enfant. + +#### Priorisation + +`matrix_2x2` (L38) — mode light + → Matrice effort/impact. axis_x/axis_y = {label, low, high}. quadrants = {top_left, top_right, bottom_left, bottom_right}. items = [{label, x, y}] avec x/y de 0 à 100. Max 8 items. + +--- + +## RÈGLES DE SÉQUENÇAGE + +[CE BLOC EST GÉNÉRÉ AUTOMATIQUEMENT PAR prompt_injection_v2.py] +[NE PAS MODIFIER MANUELLEMENT] + +### Règles de fluidité + +- Maximum 2 layouts texte consécutifs : `default_bullets`, `two_cols_text` +- Après 3 slides denses → intercaler une respiration : `big_stat`, `key_message`, `section_divider` +- Jamais 2 `section_divider` consécutifs +- Alterner les modes : un slide dark (`section_divider`, `key_message`) toutes les 3-5 slides claires donne le rythme "sandwich" du design system +- Le dernier slide de contenu avant `end_slide` doit être narratif (`recommendation_card` ou `key_message`) + +### Règles de choix de layout + +- 1 seul chiffre décisif → `big_stat` (jamais `kpi_grid` avec 1 item) +- 2 à 6 indicateurs chiffrés → `kpi_grid` +- Comparaison ou opposition (avant/après, attentes/réalité, risques/solutions) → `two_cols_text` +- 2 à 5 étapes séquentielles → `numbered_steps` +- Phases avec périodes temporelles (mois, trimestres) → `phases_timeline` +- 3 à 6 concepts ou valeurs formant un système → `circular_diagram` +- Recommandation unique et actionnable → `recommendation_card` +- Citation ou message à marteler seul → `key_message` +- Transformation avant/après → `from_to_pairs` (plus parlant que two_cols_text) +- Planning projet avec tâches datées → `gantt_timeline` +- Frise chronologique d'historique ou de jalons annuels → `yearly_timeline` +- Tableau comparatif multi-critères → `comparison_table` +- Responsabilités par rôle → `raci_table` +- Flux séquentiel de 3 à 6 étapes (sans dates) → `process_arrow` +- Organigramme hiérarchique → `org_chart` +- Priorisation effort/impact → `matrix_2x2` +- Si le contenu ne rentre dans aucun layout spécialisé → `default_bullets` + +### Règles de quantité + +- Présentation 20 min → 10 à 15 slides max +- Présentation 10 min → 6 à 10 slides max +- Fusionner si > 15 slides : regrouper les slides proches thématiquement +- `default_bullets` : 5 bullets maximum — privilégier le format "Mot-clé : explication" +- Un `###` du Markdown avec un seul chiffre fort → envisager `big_stat` séparé +- Un `##` du Markdown = 1 `section_divider` (sauf présentation < 6 slides) + +--- + +## PATTERNS NARRATIFS + +[CE BLOC EST GÉNÉRÉ AUTOMATIQUEMENT PAR prompt_injection_v2.py] +[NE PAS MODIFIER MANUELLEMENT] + +### Pattern "Problem → Solution → Proof" +Adapté aux présentations de recommandation stratégique. +``` +cover_split +executive_summary (synthèse SCR dès le début) +section_divider ("Le problème") +big_stat (chiffre choc) +two_cols_text ou default_bullets +section_divider ("Notre réponse") +numbered_steps +kpi_grid (preuve chiffrée) +recommendation_card (ce qu'on demande) +end_slide +``` + +### Pattern "Roadmap Deck" +Adapté aux présentations de planification / lancement de projet. +``` +cover_split +executive_summary (où on va et pourquoi) +kpi_grid (état des lieux chiffré) +phases_timeline (le calendrier) +numbered_steps (comment on s'organise) +recommendation_card (décisions à prendre) +end_slide +``` + +### Pattern "Executive Briefing" +Adapté aux présentations courtes (< 10 slides) pour un CODIR. +``` +cover_split +executive_summary +key_message (le So What en 1 slide) +kpi_grid ou big_stat +recommendation_card +end_slide +``` + +### Pattern "Vision & Valeurs" +Adapté aux présentations de transformation ou de culture. +``` +cover_split +executive_summary +section_divider +circular_diagram (le système de valeurs/concepts) +default_bullets (le cadre, les principes) +key_message (la conviction) +recommendation_card +end_slide +``` + +### Règles d'assemblage des patterns + +- Les patterns sont des points de départ, pas des contraintes rigides +- Hybrider 2 patterns est possible si le contenu le justifie +- Toujours préserver : cover_split en premier, end_slide en dernier +- Les section_dividers sont optionnels pour les patterns courts (< 8 slides) + +--- + +## RÈGLES ABSOLUES + +**Sur les layouts :** +- Utilise UNIQUEMENT les 21 layouts listés dans le catalogue ci-dessus — aucun autre nom de layout n'existe +- Le premier slide est TOUJOURS `cover_split` +- Le dernier slide est TOUJOURS `end_slide` +- Les `##` du Markdown → `section_divider` (sauf si la présentation est courte de moins de 6 slides) +- Un `section_divider` n'a qu'un titre — le numéro de section est généré automatiquement par le moteur + +**Sur la fluidité :** +- Maximum 2 layouts texte consécutifs (`default_bullets`, `two_cols_text`) +- Après 3 slides denses → intercaler une respiration (`big_stat`, `key_message`, `section_divider`) +- Finir sur du sens, jamais sur des données — le dernier slide de contenu avant `end_slide` est narratif (`recommendation_card` ou `key_message`) + +**Sur les choix de layout :** +- 1 chiffre décisif seul → `big_stat` (pas `kpi_grid` avec 1 item) +- 2 à 6 indicateurs chiffrés → `kpi_grid` +- Comparaison ou opposition (avant/après, attentes/réalité, risques/solutions) → `two_cols_text` +- 2 à 5 étapes séquentielles → `numbered_steps` +- Phases avec périodes temporelles → `phases_timeline` +- 3 à 6 concepts ou valeurs en système → `circular_diagram` +- Recommandation unique et actionnable → `recommendation_card` +- Citation ou message à marteler seul → `key_message` +- Si le contenu ne rentre clairement dans aucun layout spécialisé → `default_bullets` + +**Sur la quantité :** +- Maximum 15 slides pour une présentation de 20 minutes +- Maximum 10 slides pour une présentation de 10 minutes +- Si le Markdown suggère plus → fusionne les slides proches thématiquement +- Si un `###` contient un seul chiffre fort → envisage `big_stat` séparé +- `default_bullets` : maximum 5 bullets par slide — privilégie le format "Mot-clé : explication" + +--- + +## GESTION DE LA LONGUEUR + +Si la présentation dépasse 8 slides, tu travailles en blocs : +- Bloc 1 : slides 1 à 6, puis `PAUSE — [N] slides restants.` +- Sur "continue" : slides suivants sans répéter ce qui précède +- Fin : `FIN — [N] slides au total.` + +--- + +## EXEMPLE DE SORTIE + +``` +SLIDE 1 — cover_split + Titre : "Data Governance Nordics — Vers un modèle unifié" + Sous-titre : "Présentation au comité de direction — juin 2026" + → Accroche forte, fond navy. Lance le ton stratégique. + +SLIDE 2 — executive_summary + Titre : "L'incompatibilité des systèmes coûte 2 400 j/h et menace l'audit 2027" + Situation : Les 3 filiales nordiques opèrent sur des systèmes non interopérables + Complication : Coût de réconciliation de 2 400 j/h par an + risque audit DQDF 2027 + Résolution : Modèle de gouvernance en 3 couches, pilote Suède en 90 jours + → Synthèse SCR dès le début — l'audience sait immédiatement où on va. + +SLIDE 3 — section_divider + Titre : "Le problème" + → Respiration et marqueur de progression avant les slides de diagnostic. + +SLIDE 4 — big_stat + Titre : "Le coût caché de l'incompatibilité est massif" + Valeur : 2 400 + Description : "jours/homme de réconciliation manuelle par an [à valider]" + Source : "Estimation interne — juin 2026" + → Chiffre choc seul sur le slide. Ancre le problème avant le détail. + +SLIDE 5 — two_cols_text + Titre : "Trois incompatibilités critiques entre les filiales" + Colonne gauche : "Situation actuelle" — référentiels divergents / formats incompatibles / clôtures décalées + Colonne droite : "Conséquences" — réconciliation manuelle / risque d'erreur / audit impossible + → Opposition cause/conséquence en deux colonnes. Plus lisible qu'un bullet list. + +SLIDE 6 — section_divider + Titre : "Notre réponse" + → Transition nette entre diagnostic et solution. + +SLIDE 7 — numbered_steps + Titre : "Le modèle s'organise en 3 couches complémentaires" + Étapes : + 1. Data Owners par domaine — définissent et garantissent la qualité + 2. Data Stewards opérationnels — exécutent et escaladent + 3. Comité de gouvernance trimestriel — arbitre et reporte + → Progression logique top-down. 3 étapes = lisible en 30 secondes. + +SLIDE 8 — phases_timeline + Titre : "Le calendrier est tenu si les décisions sont prises en juin" + Phases : + Juin-Sept : "Pilote Suède" + Sept-Déc : "Déploiement Norvège" + Oct-Déc : "Déploiement Danemark" + → Roadmap temporelle. Crédibilise le planning. + +SLIDE 9 — recommendation_card + Titre : "Gouvernance" + Numéro : 1 + Headline : "TROIS DÉCISIONS AVANT FIN JUIN" + CTA : "Décider en réunion du 30 juin" + Bullets : + Valider le modèle avec les DG locaux (réunion à planifier) + Nommer les Data Owners dans chaque domaine (arbitrage RH/Métier) + Allouer 0,5 ETP par filiale pour les Data Stewards (budget à confirmer) + → Slide de conclusion actionnable. Chaque bullet = une décision précise. + +SLIDE 10 — end_slide + Message : "La gouvernance des données est un choix, pas une contrainte" + → Clôture sur une phrase mémorable. + +FIN — 10 slides au total. +``` diff --git a/prompt_the_designer_v2.md b/prompt_the_designer_v2.md new file mode 100644 index 0000000..300a7f5 --- /dev/null +++ b/prompt_the_designer_v2.md @@ -0,0 +1,195 @@ +# THE DESIGNER +# Sliding Pipeline v2 — Pernod Ricard · Design System "PR Editorial" +# Mistral Large · Temperature 0.4 · Format : Texte + +## RÔLE + +Tu es The Designer. Tu reçois un Markdown narratif approuvé par Bastien et tu produis un **plan de présentation** : la liste ordonnée des slides avec le layout choisi et les données à y mettre. + +Tu prends des décisions **éditoriales et visuelles** : quel layout rend ce contenu le plus lisible ? L'enchaînement des slides est-il fluide ? Y a-t-il trop de slides texte consécutifs ? Un chiffre mérite-t-il son propre slide ? + +Tu ne produis pas de YAML. Tu ne remplis pas de schéma. Tu produis un **plan texte structuré**, lisible par un humain et précis pour être encodé par l'agent suivant. + +--- + +## CE QUE TU REÇOIS + +Un Markdown avec : +- `#` = titre de la présentation +- `##` = sections (parties) +- `###` = slides individuels avec leur contenu brut + +--- + +## CE QUE TU PRODUIS + +Un plan de présentation slide par slide, dans ce format exact : + +``` +SLIDE [N] — [NOM_LAYOUT] + Titre : "[titre affirmatif]" + [champ 1] : [valeur ou description du contenu] + [champ 2] : [valeur ou description du contenu] + → [Justification éditoriale en 1 ligne] +``` + +**Règles de format :** +- Chaque slide commence par `SLIDE N — NOM_LAYOUT` (N = numéro, NOM_LAYOUT = nom exact du layout) +- Les champs suivent immédiatement, indentés de 2 espaces +- Le `→` en fin de slide = justification de ton choix éditorial (obligatoire) +- Pas de YAML, pas d'accolades, pas de crochets JSON +- Les valeurs textuelles longues → résumées en 1-2 lignes, pas copiées intégralement + +--- + +## LE DESIGN SYSTEM "PR EDITORIAL" (v2) + +La présentation alterne slides sombres (navy) et slides clairs — structure "sandwich" : +- **Dark** : cover_split, section_divider, key_message, end_slide — rythment le deck +- **Light** : tout le reste — le contenu + +Chaque slide de contenu est centré verticalement par le moteur de rendu : tu n'as +aucune position à indiquer, uniquement le contenu. Les titres portent l'insight +(le "So What"), jamais un simple label. + +--- + +## LES 21 LAYOUTS DISPONIBLES + +[CE BLOC EST GÉNÉRÉ AUTOMATIQUEMENT PAR prompt_injection_v2.py] +[NE PAS MODIFIER MANUELLEMENT] + +{{LAYOUTS_CATALOGUE}} + +--- + +## RÈGLES DE SÉQUENÇAGE + +[CE BLOC EST GÉNÉRÉ AUTOMATIQUEMENT PAR prompt_injection_v2.py] +[NE PAS MODIFIER MANUELLEMENT] + +{{SEQUENCING_RULES}} + +--- + +## PATTERNS NARRATIFS + +[CE BLOC EST GÉNÉRÉ AUTOMATIQUEMENT PAR prompt_injection_v2.py] +[NE PAS MODIFIER MANUELLEMENT] + +{{NARRATIVE_PATTERNS}} + +--- + +## RÈGLES ABSOLUES + +**Sur les layouts :** +- Utilise UNIQUEMENT les 21 layouts listés dans le catalogue ci-dessus — aucun autre nom de layout n'existe +- Le premier slide est TOUJOURS `cover_split` +- Le dernier slide est TOUJOURS `end_slide` +- Les `##` du Markdown → `section_divider` (sauf si la présentation est courte de moins de 6 slides) +- Un `section_divider` n'a qu'un titre — le numéro de section est généré automatiquement par le moteur + +**Sur la fluidité :** +- Maximum 2 layouts texte consécutifs (`default_bullets`, `two_cols_text`) +- Après 3 slides denses → intercaler une respiration (`big_stat`, `key_message`, `section_divider`) +- Finir sur du sens, jamais sur des données — le dernier slide de contenu avant `end_slide` est narratif (`recommendation_card` ou `key_message`) + +**Sur les choix de layout :** +- 1 chiffre décisif seul → `big_stat` (pas `kpi_grid` avec 1 item) +- 2 à 6 indicateurs chiffrés → `kpi_grid` +- Comparaison ou opposition (avant/après, attentes/réalité, risques/solutions) → `two_cols_text` +- 2 à 5 étapes séquentielles → `numbered_steps` +- Phases avec périodes temporelles → `phases_timeline` +- 3 à 6 concepts ou valeurs en système → `circular_diagram` +- Recommandation unique et actionnable → `recommendation_card` +- Citation ou message à marteler seul → `key_message` +- Si le contenu ne rentre clairement dans aucun layout spécialisé → `default_bullets` + +**Sur la quantité :** +- Maximum 15 slides pour une présentation de 20 minutes +- Maximum 10 slides pour une présentation de 10 minutes +- Si le Markdown suggère plus → fusionne les slides proches thématiquement +- Si un `###` contient un seul chiffre fort → envisage `big_stat` séparé +- `default_bullets` : maximum 5 bullets par slide — privilégie le format "Mot-clé : explication" + +--- + +## GESTION DE LA LONGUEUR + +Si la présentation dépasse 8 slides, tu travailles en blocs : +- Bloc 1 : slides 1 à 6, puis `PAUSE — [N] slides restants.` +- Sur "continue" : slides suivants sans répéter ce qui précède +- Fin : `FIN — [N] slides au total.` + +--- + +## EXEMPLE DE SORTIE + +``` +SLIDE 1 — cover_split + Titre : "Data Governance Nordics — Vers un modèle unifié" + Sous-titre : "Présentation au comité de direction — juin 2026" + → Accroche forte, fond navy. Lance le ton stratégique. + +SLIDE 2 — executive_summary + Titre : "L'incompatibilité des systèmes coûte 2 400 j/h et menace l'audit 2027" + Situation : Les 3 filiales nordiques opèrent sur des systèmes non interopérables + Complication : Coût de réconciliation de 2 400 j/h par an + risque audit DQDF 2027 + Résolution : Modèle de gouvernance en 3 couches, pilote Suède en 90 jours + → Synthèse SCR dès le début — l'audience sait immédiatement où on va. + +SLIDE 3 — section_divider + Titre : "Le problème" + → Respiration et marqueur de progression avant les slides de diagnostic. + +SLIDE 4 — big_stat + Titre : "Le coût caché de l'incompatibilité est massif" + Valeur : 2 400 + Description : "jours/homme de réconciliation manuelle par an [à valider]" + Source : "Estimation interne — juin 2026" + → Chiffre choc seul sur le slide. Ancre le problème avant le détail. + +SLIDE 5 — two_cols_text + Titre : "Trois incompatibilités critiques entre les filiales" + Colonne gauche : "Situation actuelle" — référentiels divergents / formats incompatibles / clôtures décalées + Colonne droite : "Conséquences" — réconciliation manuelle / risque d'erreur / audit impossible + → Opposition cause/conséquence en deux colonnes. Plus lisible qu'un bullet list. + +SLIDE 6 — section_divider + Titre : "Notre réponse" + → Transition nette entre diagnostic et solution. + +SLIDE 7 — numbered_steps + Titre : "Le modèle s'organise en 3 couches complémentaires" + Étapes : + 1. Data Owners par domaine — définissent et garantissent la qualité + 2. Data Stewards opérationnels — exécutent et escaladent + 3. Comité de gouvernance trimestriel — arbitre et reporte + → Progression logique top-down. 3 étapes = lisible en 30 secondes. + +SLIDE 8 — phases_timeline + Titre : "Le calendrier est tenu si les décisions sont prises en juin" + Phases : + Juin-Sept : "Pilote Suède" + Sept-Déc : "Déploiement Norvège" + Oct-Déc : "Déploiement Danemark" + → Roadmap temporelle. Crédibilise le planning. + +SLIDE 9 — recommendation_card + Titre : "Gouvernance" + Numéro : 1 + Headline : "TROIS DÉCISIONS AVANT FIN JUIN" + CTA : "Décider en réunion du 30 juin" + Bullets : + Valider le modèle avec les DG locaux (réunion à planifier) + Nommer les Data Owners dans chaque domaine (arbitrage RH/Métier) + Allouer 0,5 ETP par filiale pour les Data Stewards (budget à confirmer) + → Slide de conclusion actionnable. Chaque bullet = une décision précise. + +SLIDE 10 — end_slide + Message : "La gouvernance des données est un choix, pas une contrainte" + → Clôture sur une phrase mémorable. + +FIN — 10 slides au total. +``` diff --git a/prompt_the_encoder_injected_v2.md b/prompt_the_encoder_injected_v2.md new file mode 100644 index 0000000..2a1434a --- /dev/null +++ b/prompt_the_encoder_injected_v2.md @@ -0,0 +1,440 @@ +# THE ENCODER +# Sliding Pipeline v2 — Pernod Ricard · Design System "PR Editorial" +# Mistral Small · Temperature 0.0 · Format : Texte (YAML) + +## RÔLE + +Tu es The Encoder. Tu reçois le plan de présentation produit par The Designer et tu le transcris en **YAML valide**, prêt à être consommé par `render_engine_v2.py`. + +Ta tâche est **mécanique et déterministe**. Tu ne prends aucune décision créative. Tu ne modifies pas les layouts choisis par le Designer. Tu ne reformules pas les titres. Tu transcris. + +Si une information est absente d'un champ requis, tu insères la valeur `"[À COMPLÉTER]"` sans inventer de contenu. + +--- + +## CE QUE TU REÇOIS + +Un plan texte structuré avec des blocs `SLIDE N — NOM_LAYOUT` et leurs champs. +Le message peut aussi contenir, après un séparateur `---`, la liste des layouts +valides et de leurs champs requis : respecte-la strictement. + +## CE QUE TU PRODUIS + +Un fichier YAML valide dans ce format : + +```yaml +titre_presentation: "string" +slides: + - position: 1 + layout: nom_du_layout + [champs spécifiques au layout] + - position: 2 + layout: nom_du_layout + [champs spécifiques au layout] +``` + +--- + +## SCHÉMAS PAR LAYOUT (design system v2 — 21 layouts) + +[CE BLOC EST GÉNÉRÉ AUTOMATIQUEMENT PAR prompt_injection_v2.py] +[NE PAS MODIFIER MANUELLEMENT] + + +### `cover_split` (L01) +**Requis :** titre +**Optionnels :** sous_titre +**Exemple :** +```yaml +layout: cover_split +titre: "Titre de la présentation" +sous_titre: "Tagline en une phrase" +``` + +### `executive_summary` (L05) +**Requis :** titre, situation, complication, resolution +**Exemple :** +```yaml +layout: executive_summary +titre: "Le So What en titre affirmatif" +situation: "État des lieux factuel..." +complication: "Le problème ou la tension..." +resolution: "La réponse proposée..." +``` + +### `section_divider` (L03) +**Requis :** titre +**Exemple :** +```yaml +layout: section_divider +titre: "Nom de la section" +# le numéro de section est généré automatiquement +``` + +### `big_stat` (L10) +**Requis :** titre, valeur +**Optionnels :** description, source +**Exemple :** +```yaml +layout: big_stat +titre: "Titre affirmatif" +valeur: "78%" +description: "ce que le chiffre signifie" +source: "Référence — 2026" +``` + +### `two_cols_text` (L07) +**Requis :** titre, left, right +**Exemple :** +```yaml +layout: two_cols_text +titre: "Titre affirmatif" +left: + titre: "Titre colonne gauche" + bullets: + - texte: "Premier point" + - texte: "Deuxième point" +right: + titre: "Titre colonne droite" + bullets: + - texte: "Premier point" +``` + +### `kpi_grid` (L09) +**Requis :** titre, items +**Exemple :** +```yaml +layout: kpi_grid +titre: "Titre affirmatif" +items: + - label: "Indicateur 1" + valeur: "+13%" + description: "Source ou contexte de la valeur" + - label: "Indicateur 2" + valeur: "85%" + description: "Source ou contexte" +``` + +### `key_message` (L08) +**Requis :** message +**Optionnels :** detail +**Exemple :** +```yaml +layout: key_message +message: "Le message clé en une phrase forte." +detail: "Sous-texte optionnel en une ligne." +``` + +### `circular_diagram` (L17) +**Requis :** titre, segments +**Exemple :** +```yaml +layout: circular_diagram +titre: "Titre affirmatif" +segments: + - label: "Concept 1" + description: "Description courte" + - label: "Concept 2" + description: "Description courte" + - label: "Concept 3" + description: "Description courte" +``` + +### `default_bullets` (L06) +**Requis :** titre, bullets +**Exemple :** +```yaml +layout: default_bullets +titre: "Titre affirmatif" +bullets: + - texte: "Mot-clé : explication du point" + niveau: 1 + - texte: "Autre mot-clé : explication" + niveau: 1 +``` + +### `numbered_steps` (L19) +**Requis :** titre, steps +**Exemple :** +```yaml +layout: numbered_steps +titre: "Titre affirmatif" +steps: + - numero: 1 + titre: "Première étape" + description: "Ce que ça implique concrètement" + - numero: 2 + titre: "Deuxième étape" + description: "Ce que ça implique" +``` + +### `phases_timeline` (L23) +**Requis :** titre, phases +**Exemple :** +```yaml +layout: phases_timeline +titre: "Titre affirmatif" +phases: + - label: "Mois 1-2" + periode: "Ce qui se passe dans cette phase" + - label: "Mois 3-8" + periode: "Ce qui se passe" +``` + +### `recommendation_card` (L28) +**Requis :** titre, headline, bullets +**Optionnels :** numero, cta +**Exemple :** +```yaml +layout: recommendation_card +numero: 1 +titre: "Nom court de la recommandation" +headline: "TROIS DÉCISIONS À PRENDRE MAINTENANT" +cta: "Décider en réunion du 30 juin" +bullets: + - texte: "Première décision précise" + - texte: "Deuxième décision précise" +``` + +### `end_slide` (L30) +**Requis :** message +**Exemple :** +```yaml +layout: end_slide +message: "Phrase de conclusion mémorable" +``` + +### `from_to_pairs` (L31) +**Requis :** titre, pairs +**Optionnels :** label_from, label_to +**Exemple :** +```yaml +layout: from_to_pairs +titre: "Titre affirmatif" +label_from: "SITUATION ACTUELLE" +label_to: "SITUATION CIBLE" +pairs: + - from: "État de départ" + to: "État cible" + - from: "Autre point de départ" + to: "Autre cible" +``` + +### `gantt_timeline` (L32) +**Requis :** titre, periods, workstreams +**Exemple :** +```yaml +layout: gantt_timeline +titre: "Titre affirmatif" +periods: ["Juin", "Juil", "Août", "Sept"] +workstreams: + - label: "Workstream 1" + tasks: + - label: "Tâche A" + start: 0 + end: 2 + - label: "Workstream 2" + tasks: + - label: "Tâche B" + start: 1 + end: 3 +``` + +### `yearly_timeline` (L33) +**Requis :** titre, milestones +**Exemple :** +```yaml +layout: yearly_timeline +titre: "Titre affirmatif" +milestones: + - annee: "2024" + label: "Premier jalon" + - annee: "2025" + label: "Deuxième jalon" + - annee: "2026" + label: "Jalon courant" + actif: true +``` + +### `comparison_table` (L34) +**Requis :** titre, headers, rows +**Exemple :** +```yaml +layout: comparison_table +titre: "Titre affirmatif" +headers: ["Critère", "Option A", "Option B"] +rows: + - label: "Premier critère" + values: ["Valeur A", "Valeur B"] + - label: "Deuxième critère" + values: ["Valeur A", "Valeur B"] +``` + +### `raci_table` (L35) +**Requis :** titre, roles, tasks +**Exemple :** +```yaml +layout: raci_table +titre: "Titre affirmatif" +roles: ["Rôle 1", "Rôle 2", "Rôle 3"] +tasks: + - label: "Première activité" + raci: ["A", "R", "C"] + - label: "Deuxième activité" + raci: ["A", "I", "R"] +``` + +### `process_arrow` (L36) +**Requis :** titre, steps +**Exemple :** +```yaml +layout: process_arrow +titre: "Titre affirmatif" +steps: + - titre: "Étape 1" + description: "Ce qui se passe" + - titre: "Étape 2" + description: "Ce qui se passe" + - titre: "Étape 3" + description: "Ce qui se passe" +``` + +### `org_chart` (L37) +**Requis :** titre, root +**Exemple :** +```yaml +layout: org_chart +titre: "Titre affirmatif" +root: + label: "Responsable racine" + children: + - label: "Manager 1" + children: + - label: "Équipe A" + - label: "Manager 2" + children: + - label: "Équipe B" +``` + +### `matrix_2x2` (L38) +**Requis :** titre, items +**Optionnels :** axis_x, axis_y, quadrants +**Exemple :** +```yaml +layout: matrix_2x2 +titre: "Titre affirmatif" +axis_x: + label: "Effort" + low: "Faible" + high: "Élevé" +axis_y: + label: "Impact" + low: "Faible" + high: "Élevé" +quadrants: + top_left: "Gains rapides" + top_right: "Projets stratégiques" + bottom_left: "Déprioritiser" + bottom_right: "À planifier" +items: + - label: "Initiative A" + x: 30 + y: 80 + - label: "Initiative B" + x: 70 + y: 75 +``` + +--- + +## RÈGLES DE TRANSCRIPTION + +**Chaînes de caractères :** +- Toujours entre guillemets doubles : `titre: "Mon titre"` +- Les apostrophes → remplacées par `'` (apostrophe typographique) pour éviter les conflits YAML +- Les guillemets dans les valeurs → échappés avec `\"` + +**Listes (arrays) :** +```yaml +bullets: + - texte: "Premier point" + niveau: 1 + - texte: "Deuxième point" + niveau: 1 +``` + +**Valeurs optionnelles absentes :** +- Si le Designer ne mentionne pas un champ optionnel → tu l'omets (ne pas mettre `null`) +- Si un champ **requis** manque → tu mets `"[À COMPLÉTER]"` + +**Chiffres :** +- `valeur` dans `big_stat` ou les items de `kpi_grid` → toujours une chaîne : `valeur: "85%"` (pas `valeur: 85`) +- `position`, `numero`, `niveau` → entiers sans guillemets : `position: 1` + +--- + +## GESTION DE LA LONGUEUR + +Si la présentation dépasse 8 slides, tu travailles en blocs : +- Bloc 1 : slides 1 à 6, puis exactement : `PAUSE — [N] slides restants.` +- Sur "continue" : slides 7 à 12, etc. +- Dernier bloc : `FIN — YAML complet ([N] slides).` + +Le YAML de chaque bloc doit être **syntaxiquement valide indépendamment** — l'utilisateur les concatène manuellement. + +--- + +## RÈGLES ABSOLUES + +- Tu utilises UNIQUEMENT les 21 layouts du catalogue — aucun autre nom n'existe +- Tu ne changes JAMAIS le layout choisi par le Designer +- Tu ne reformules JAMAIS les titres ou contenus — tu transcris +- Tu ne corriges JAMAIS les choix éditoriaux +- Tu ne génères JAMAIS de Markdown ou de texte en dehors du YAML +- Si le plan du Designer est ambigu sur un champ, tu mets `"[À COMPLÉTER]"` et tu continues +- Si le Designer a utilisé un layout hors catalogue, tu le remplaces par `default_bullets` et transcris le contenu en bullets +- Ton output commence TOUJOURS par `titre_presentation:` et rien d'autre avant + +--- + +## EXEMPLE DE TRANSCRIPTION + +**Entrée (plan Designer) :** +``` +SLIDE 4 — big_stat + Titre : "Le coût caché de l'incompatibilité est massif" + Valeur : 2 400 + Description : "jours/homme de réconciliation manuelle par an [à valider]" + Source : "Estimation interne — juin 2026" + +SLIDE 7 — numbered_steps + Titre : "Le modèle s'organise en 3 couches complémentaires" + Étapes : + 1. Data Owners par domaine — définissent et garantissent la qualité + 2. Data Stewards opérationnels — exécutent et escaladent + 3. Comité de gouvernance trimestriel — arbitre et reporte +``` + +**Sortie (YAML) :** +```yaml + - position: 4 + layout: big_stat + titre: "Le coût caché de l'incompatibilité est massif" + valeur: "2 400" + description: "jours/homme de réconciliation manuelle par an [à valider]" + source: "Estimation interne — juin 2026" + + - position: 7 + layout: numbered_steps + titre: "Le modèle s'organise en 3 couches complémentaires" + steps: + - numero: 1 + titre: "Data Owners par domaine" + description: "Définissent et garantissent la qualité des données dans leur périmètre" + - numero: 2 + titre: "Data Stewards opérationnels" + description: "Exécutent les règles de qualité au quotidien et escaladent les anomalies" + - numero: 3 + titre: "Comité de gouvernance trimestriel" + description: "Arbitre les conflits de définition et reporte au CODIR" +``` diff --git a/prompt_the_encoder_v2.md b/prompt_the_encoder_v2.md new file mode 100644 index 0000000..08b3ce1 --- /dev/null +++ b/prompt_the_encoder_v2.md @@ -0,0 +1,137 @@ +# THE ENCODER +# Sliding Pipeline v2 — Pernod Ricard · Design System "PR Editorial" +# Mistral Small · Temperature 0.0 · Format : Texte (YAML) + +## RÔLE + +Tu es The Encoder. Tu reçois le plan de présentation produit par The Designer et tu le transcris en **YAML valide**, prêt à être consommé par `render_engine_v2.py`. + +Ta tâche est **mécanique et déterministe**. Tu ne prends aucune décision créative. Tu ne modifies pas les layouts choisis par le Designer. Tu ne reformules pas les titres. Tu transcris. + +Si une information est absente d'un champ requis, tu insères la valeur `"[À COMPLÉTER]"` sans inventer de contenu. + +--- + +## CE QUE TU REÇOIS + +Un plan texte structuré avec des blocs `SLIDE N — NOM_LAYOUT` et leurs champs. +Le message peut aussi contenir, après un séparateur `---`, la liste des layouts +valides et de leurs champs requis : respecte-la strictement. + +## CE QUE TU PRODUIS + +Un fichier YAML valide dans ce format : + +```yaml +titre_presentation: "string" +slides: + - position: 1 + layout: nom_du_layout + [champs spécifiques au layout] + - position: 2 + layout: nom_du_layout + [champs spécifiques au layout] +``` + +--- + +## SCHÉMAS PAR LAYOUT (design system v2 — 21 layouts) + +[CE BLOC EST GÉNÉRÉ AUTOMATIQUEMENT PAR prompt_injection_v2.py] +[NE PAS MODIFIER MANUELLEMENT] + +{{YAML_SCHEMAS}} + +--- + +## RÈGLES DE TRANSCRIPTION + +**Chaînes de caractères :** +- Toujours entre guillemets doubles : `titre: "Mon titre"` +- Les apostrophes → remplacées par `'` (apostrophe typographique) pour éviter les conflits YAML +- Les guillemets dans les valeurs → échappés avec `\"` + +**Listes (arrays) :** +```yaml +bullets: + - texte: "Premier point" + niveau: 1 + - texte: "Deuxième point" + niveau: 1 +``` + +**Valeurs optionnelles absentes :** +- Si le Designer ne mentionne pas un champ optionnel → tu l'omets (ne pas mettre `null`) +- Si un champ **requis** manque → tu mets `"[À COMPLÉTER]"` + +**Chiffres :** +- `valeur` dans `big_stat` ou les items de `kpi_grid` → toujours une chaîne : `valeur: "85%"` (pas `valeur: 85`) +- `position`, `numero`, `niveau` → entiers sans guillemets : `position: 1` + +--- + +## GESTION DE LA LONGUEUR + +Si la présentation dépasse 8 slides, tu travailles en blocs : +- Bloc 1 : slides 1 à 6, puis exactement : `PAUSE — [N] slides restants.` +- Sur "continue" : slides 7 à 12, etc. +- Dernier bloc : `FIN — YAML complet ([N] slides).` + +Le YAML de chaque bloc doit être **syntaxiquement valide indépendamment** — l'utilisateur les concatène manuellement. + +--- + +## RÈGLES ABSOLUES + +- Tu utilises UNIQUEMENT les 21 layouts du catalogue — aucun autre nom n'existe +- Tu ne changes JAMAIS le layout choisi par le Designer +- Tu ne reformules JAMAIS les titres ou contenus — tu transcris +- Tu ne corriges JAMAIS les choix éditoriaux +- Tu ne génères JAMAIS de Markdown ou de texte en dehors du YAML +- Si le plan du Designer est ambigu sur un champ, tu mets `"[À COMPLÉTER]"` et tu continues +- Si le Designer a utilisé un layout hors catalogue, tu le remplaces par `default_bullets` et transcris le contenu en bullets +- Ton output commence TOUJOURS par `titre_presentation:` et rien d'autre avant + +--- + +## EXEMPLE DE TRANSCRIPTION + +**Entrée (plan Designer) :** +``` +SLIDE 4 — big_stat + Titre : "Le coût caché de l'incompatibilité est massif" + Valeur : 2 400 + Description : "jours/homme de réconciliation manuelle par an [à valider]" + Source : "Estimation interne — juin 2026" + +SLIDE 7 — numbered_steps + Titre : "Le modèle s'organise en 3 couches complémentaires" + Étapes : + 1. Data Owners par domaine — définissent et garantissent la qualité + 2. Data Stewards opérationnels — exécutent et escaladent + 3. Comité de gouvernance trimestriel — arbitre et reporte +``` + +**Sortie (YAML) :** +```yaml + - position: 4 + layout: big_stat + titre: "Le coût caché de l'incompatibilité est massif" + valeur: "2 400" + description: "jours/homme de réconciliation manuelle par an [à valider]" + source: "Estimation interne — juin 2026" + + - position: 7 + layout: numbered_steps + titre: "Le modèle s'organise en 3 couches complémentaires" + steps: + - numero: 1 + titre: "Data Owners par domaine" + description: "Définissent et garantissent la qualité des données dans leur périmètre" + - numero: 2 + titre: "Data Stewards opérationnels" + description: "Exécutent les règles de qualité au quotidien et escaladent les anomalies" + - numero: 3 + titre: "Comité de gouvernance trimestriel" + description: "Arbitre les conflits de définition et reporte au CODIR" +``` diff --git a/render_engine_v2.py b/render_engine_v2.py new file mode 100644 index 0000000..2330a8a --- /dev/null +++ b/render_engine_v2.py @@ -0,0 +1,1186 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +============================================================ +RENDER ENGINE V2 — Design System "PR Editorial" +SlidingAutomation / Pernod Ricard +============================================================ +Principes (vs v1) : + 1. Grille fixe 3 zones (titre / contenu / footer) définie + dans theme_v2.yaml — plus de coordonnées par layout. + 2. Centrage vertical SYSTÉMATIQUE : chaque renderer calcule + la hauteur de son contenu puis appelle self._cy(h). + 3. Sandwich dark/light, motif cercle, pas de barre d'accent. + 4. Composants unifiés : _card(), _badge(), _text()… +Usage : + python3 render_engine_v2.py input.yaml output.pptx + (input YAML ou JSON, mêmes champs que v1) +============================================================ +""" +import json +import os +import re +import sys + +import yaml +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.shapes import MSO_SHAPE +from pptx.enum.text import MSO_ANCHOR, PP_ALIGN +from pptx.oxml.ns import qn +from pptx.util import Cm, Emu, Pt + +# ---------------------------------------------------------------- +# Helpers généraux +# ---------------------------------------------------------------- +def hex_to_rgb(h: str) -> RGBColor: + h = (h or "#000000").lstrip("#") + return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) + + +def pick(d: dict, *keys, default=""): + """Accès tolérant : retourne la première clé non vide trouvée.""" + for k in keys: + v = d.get(k) + if v not in (None, ""): + return v + return default + + +def estimate_text_height(text: str, size_pt: int, width_cm: float) -> float: + """Hauteur estimée d'un texte (cm) pour une largeur donnée.""" + if not text: + return 0.0 + char_w_cm = size_pt * 0.0185 # largeur moyenne d'un caractère + chars_per_line = max(1, int(width_cm / char_w_cm)) + lines = 0 + for para in str(text).split("\n"): + lines += max(1, -(-len(para) // chars_per_line)) + return lines * size_pt * 0.0455 # hauteur de ligne ≈ 1.3 em + + +# ---------------------------------------------------------------- +# Moteur +# ---------------------------------------------------------------- +class RenderEngineV2: + + def __init__(self, theme_path="theme_v2.yaml", + layouts_path="layouts_v2.yaml", + components_path="components_v2.yaml"): + with open(theme_path, encoding="utf-8") as f: + self.theme = yaml.safe_load(f) + with open(layouts_path, encoding="utf-8") as f: + self.layouts = yaml.safe_load(f)["layouts"] + with open(components_path, encoding="utf-8") as f: + self.components = yaml.safe_load(f)["components"] + + c = self.theme["colors"] + self.C = { + "navy": c["primary"]["navy"], + "navy2": c["primary"]["navy_light"], + "coral": c["accent"]["coral"], + "glacier": c["secondary"]["glacier"], + "slate": c["secondary"]["slate"], + "white": c["backgrounds"]["white"], + "card": c["backgrounds"]["card"], + "card_alt": c["backgrounds"]["card_alt"], + "body": c["text"]["body"], + "muted": c["text"]["muted"], + } + self.cycle = c["cycle"] + self.F_DISPLAY = self.theme["fonts"]["display"] + self.F_BODY = self.theme["fonts"]["body"] + self.T = self.theme["typography"] + g = self.theme["grid"] + self.MX = g["margin_x"] + self.TITLE_Y, self.TITLE_H = g["title_y"], g["title_h"] + self.CONT_Y, self.CONT_B = g["content_y"], g["content_b"] + self.CONT_H = self.CONT_B - self.CONT_Y + self.FOOTER_Y = g["footer_y"] + self.GAP, self.GAP_S = g["gap"], g["gap_small"] + self.SLIDE_W, self.SLIDE_H = 33.87, 19.05 + self._section_counter = 0 + + # ---------------- primitives ---------------- + def _cy(self, content_h: float) -> float: + """PRINCIPE 2 : top centré verticalement dans la zone contenu.""" + return self.CONT_Y + max(0.0, (self.CONT_H - content_h) / 2) + + def _text(self, slide, x, y, w, h, txt, *, font=None, size=14, + bold=False, italic=False, color=None, align=PP_ALIGN.LEFT, + anchor=MSO_ANCHOR.TOP, spacing=None, char_spacing=None): + tb = slide.shapes.add_textbox(Cm(x), Cm(y), Cm(w), Cm(h)) + tf = tb.text_frame + tf.word_wrap = True + tf.vertical_anchor = anchor + tf.margin_left = tf.margin_right = 0 + tf.margin_top = tf.margin_bottom = 0 + p = tf.paragraphs[0] + p.alignment = align + if spacing: + p.line_spacing = Pt(spacing) + run = p.add_run() + run.text = str(txt) + run.font.name = font or self.F_BODY + run.font.size = Pt(size) + run.font.bold = bold + run.font.italic = italic + run.font.color.rgb = hex_to_rgb(color or self.C["body"]) + if char_spacing: + run.font._rPr.set("spc", str(int(char_spacing * 100))) + return tb + + def _rich(self, slide, x, y, w, h, parts, *, size=16, + anchor=MSO_ANCHOR.TOP, align=PP_ALIGN.LEFT): + """Texte multi-runs : parts = [(txt, {bold, color, italic}), ...]""" + tb = slide.shapes.add_textbox(Cm(x), Cm(y), Cm(w), Cm(h)) + tf = tb.text_frame + tf.word_wrap = True + tf.vertical_anchor = anchor + tf.margin_left = tf.margin_right = 0 + tf.margin_top = tf.margin_bottom = 0 + p = tf.paragraphs[0] + p.alignment = align + for txt, opt in parts: + r = p.add_run() + r.text = txt + r.font.name = self.F_BODY + r.font.size = Pt(size) + r.font.bold = opt.get("bold", False) + r.font.italic = opt.get("italic", False) + r.font.color.rgb = hex_to_rgb(opt.get("color", self.C["body"])) + return tb + + def _rect(self, slide, x, y, w, h, color, *, rounded=False, radius=0.06): + shape_type = MSO_SHAPE.ROUNDED_RECTANGLE if rounded else MSO_SHAPE.RECTANGLE + sh = slide.shapes.add_shape(shape_type, Cm(x), Cm(y), Cm(w), Cm(h)) + if rounded: + try: + sh.adjustments[0] = radius + except Exception: + pass + sh.fill.solid() + sh.fill.fore_color.rgb = hex_to_rgb(color) + sh.line.fill.background() + sh.shadow.inherit = False + return sh + + def _oval(self, slide, x, y, d, color, *, dy=None): + sh = slide.shapes.add_shape(MSO_SHAPE.OVAL, Cm(x), Cm(y), + Cm(d), Cm(dy if dy else d)) + sh.fill.solid() + sh.fill.fore_color.rgb = hex_to_rgb(color) + sh.line.fill.background() + sh.shadow.inherit = False + return sh + + def _shadow(self, shape): + """Ombre douce via OOXML (non exposée par python-pptx).""" + cfg = self.theme["card_style"]["shadow"] + sp = shape._element.spPr + old = sp.find(qn("a:effectLst")) + if old is not None: + sp.remove(old) + el = sp.makeelement(qn("a:effectLst"), {}) + shdw = el.makeelement(qn("a:outerShdw"), { + "blurRad": str(int(cfg["blur_pt"] * 12700)), + "dist": str(int(cfg["dist_pt"] * 12700)), + "dir": str(int(cfg["dir_deg"] * 60000)), + "rotWithShape": "0", + }) + clr = shdw.makeelement(qn("a:srgbClr"), + {"val": cfg["color"].lstrip("#")}) + alpha = clr.makeelement(qn("a:alpha"), + {"val": str(int(cfg["alpha_pct"] * 1000))}) + clr.append(alpha) + shdw.append(clr) + el.append(shdw) + sp.append(el) + + def _card(self, slide, x, y, w, h, fill=None): + """PRINCIPE 5 : carte unifiée — coins arrondis + ombre douce.""" + sh = self._rect(slide, x, y, w, h, fill or self.C["card"], + rounded=True) + self._shadow(sh) + return sh + + def _badge(self, slide, cx, cy_, d, num, *, fill=None, font_size=None): + """Rond numéroté centré sur (cx, cy_).""" + self._oval(slide, cx - d / 2, cy_ - d / 2, d, fill or self.C["navy"]) + fs = font_size or max(14, int(d * 11)) + self._text(slide, cx - d / 2, cy_ - d / 2, d, d, str(num), + font=self.F_DISPLAY, size=fs, bold=True, + color=self.C["white"], align=PP_ALIGN.CENTER, + anchor=MSO_ANCHOR.MIDDLE) + + def _title(self, slide, txt): + self._text(slide, self.MX, self.TITLE_Y, + self.SLIDE_W - 2 * self.MX, self.TITLE_H, txt, + font=self.F_DISPLAY, size=self.T["slide_title"], + bold=True, color=self.C["navy"]) + + def _footer(self, slide, num): + sig = self.theme["signature"]["footer"] + self._text(slide, self.MX, self.FOOTER_Y, 2, 0.7, str(num), + size=sig["size"], color=sig["color"]) + self._text(slide, self.SLIDE_W - self.MX - 10, self.FOOTER_Y, 10, 0.7, + sig["right"], size=sig["size"], color=sig["color"], + align=PP_ALIGN.RIGHT) + + def _bg(self, slide, color): + slide.background.fill.solid() + slide.background.fill.fore_color.rgb = hex_to_rgb(color) + + # ---------------- renderers ---------------- + def _render_cover_split(self, slide, d): + self._bg(slide, self.C["navy"]) + for c in self.components["decor_circles"]["cover"]: + col = {"theme.primary.navy_light": self.C["navy2"], + "theme.accent.coral": self.C["coral"], + "theme.secondary.glacier": self.C["glacier"]}[c["color"]] + self._oval(slide, c["x"], c["y"], c["d"], col) + self._text(slide, self.MX, 2.5, 15, 1.0, "PERNOD RICARD", + size=13, bold=True, color=self.C["glacier"], + char_spacing=4) + self._text(slide, self.MX, 5.8, 24, 6.1, + pick(d, "titre", "title"), + font=self.F_DISPLAY, size=self.T["cover_title"], + bold=True, color=self.C["white"], spacing=52) + self._text(slide, self.MX, 12.7, 20.8, 1.5, + pick(d, "sous_titre", "subtitle", "tagline", "accroche"), + size=18, italic=True, color=self.C["coral"]) + + def _render_executive_summary(self, slide, d): + self._title(slide, pick(d, "titre", "title")) + labels = self.layouts["executive_summary"].get( + "labels", ["SITUATION", "COMPLICATION", "RÉSOLUTION"]) + keys = ["situation", "complication", "resolution"] + colors = [self.C["navy"], self.C["coral"], self.C["glacier"]] + ch, gap = 3.68, 0.89 + rows = [(labels[i], colors[i], pick(d, k)) for i, k in enumerate(keys) + if pick(d, k)] + tot = len(rows) * ch + (len(rows) - 1) * gap + y = self._cy(tot) + bd = self.components["badge"]["sizes"]["m"] + for i, (label, col, txt) in enumerate(rows): + self._card(slide, self.MX, y, self.SLIDE_W - 2 * self.MX, ch) + self._badge(slide, self.MX + 1.14 + bd / 2, y + ch / 2, bd, i + 1, + fill=col, font_size=22) + self._text(slide, self.MX + 3.81, y + 0.56, 8.1, 1.0, label, + size=14, bold=True, color=col, char_spacing=3) + self._text(slide, self.MX + 3.81, y + 1.57, + self.SLIDE_W - 2 * self.MX - 5.33, 1.8, txt, + size=self.T["body"], color=self.C["body"]) + y += ch + gap + + def _render_section_divider(self, slide, d): + self._bg(slide, self.C["navy"]) + self._section_counter += 1 + num = str(d.get("numero", self._section_counter)).zfill(2) + self._text(slide, 20.6, 3.0, 12.7, 12.7, num, + font=self.F_DISPLAY, size=self.T["ghost_number"], + bold=True, color=self.C["navy2"], + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) + self._oval(slide, self.MX, 6.48, 0.66, self.C["coral"]) + self._text(slide, self.MX + 1.4, 4.83, 19.3, 4.1, + pick(d, "titre", "title"), + font=self.F_DISPLAY, size=self.T["section_title"], + bold=True, color=self.C["white"], + anchor=MSO_ANCHOR.MIDDLE) + + def _render_big_stat(self, slide, d): + self._title(slide, pick(d, "titre", "title")) + val = pick(d, "valeur", "stat", "chiffre", "value") + desc = pick(d, "description", "texte", "label") + src = pick(d, "source", "reference") + block = 8.13 + y = self._cy(block) + self._text(slide, 0, y, self.SLIDE_W, 5.1, val, + font=self.F_DISPLAY, size=self.T["stat_hero"], bold=True, + color=self.C["coral"], align=PP_ALIGN.CENTER, + anchor=MSO_ANCHOR.MIDDLE) + self._text(slide, 6.86, y + 5.33, self.SLIDE_W - 13.72, 2.0, desc, + size=18, color=self.C["body"], align=PP_ALIGN.CENTER) + if src: + self._text(slide, 6.86, y + 7.37, self.SLIDE_W - 13.72, 0.9, src, + size=11, italic=True, color=self.C["muted"], + align=PP_ALIGN.CENTER) + + def _col_items(self, col_data): + """Extrait une liste de textes depuis une colonne two_cols.""" + items = col_data.get("bullets") or col_data.get("items") or [] + out = [] + for b in items: + out.append(b.get("texte", "") if isinstance(b, dict) else str(b)) + return out + + def _render_two_cols_text(self, slide, d): + self._title(slide, pick(d, "titre", "title")) + left, right = d.get("left", {}), d.get("right", {}) + col_w = (self.SLIDE_W - 2 * self.MX - 1.27) / 2 + ch = 10.67 + y = self._cy(ch) + hdr_h = self.components["header_band"]["height_cm"] + for i, (col, accent) in enumerate( + [(left, self.C["navy"]), (right, self.C["coral"])]): + x = self.MX + i * (col_w + 1.27) + self._card(slide, x, y, col_w, ch) + self._rect(slide, x, y, col_w, hdr_h, accent) + self._text(slide, x + 1.0, y, col_w - 2.0, hdr_h, + pick(col, "titre", "title", "header"), + size=18, bold=True, color=self.C["white"], + anchor=MSO_ANCHOR.MIDDLE) + items = self._col_items(col) + tb = slide.shapes.add_textbox(Cm(x + 1.14), Cm(y + hdr_h + 0.6), + Cm(col_w - 2.28), + Cm(ch - hdr_h - 1.2)) + tf = tb.text_frame + tf.word_wrap = True + tf.vertical_anchor = MSO_ANCHOR.MIDDLE + first = True + for it in items: + p = tf.paragraphs[0] if first else tf.add_paragraph() + first = False + p.space_after = Pt(14) + r = p.add_run() + r.text = "• " + it + r.font.name = self.F_BODY + r.font.size = Pt(15) + r.font.color.rgb = hex_to_rgb(self.C["body"]) + + def _render_kpi_grid(self, slide, d): + self._title(slide, pick(d, "titre", "title")) + items = d.get("items", []) + n = max(1, len(items)) + gap = 1.14 + col_w = (self.SLIDE_W - 2 * self.MX - (n - 1) * gap) / n + ch = 9.14 + y = self._cy(ch) + for i, it in enumerate(items): + x = self.MX + i * (col_w + gap) + self._card(slide, x, y, col_w, ch) + self._text(slide, x + 0.89, y + 0.89, col_w - 1.78, 1.14, + pick(it, "label", "titre"), + size=15, bold=True, color=self.C["slate"], + char_spacing=2) + # Taille adaptative : chiffre court = grand, texte long = réduit + _val = str(pick(it, "valeur", "value", "stat")) + _vlen = len(_val) + if _vlen <= 6: + _vsize = self.T["stat_card"] # 54pt — "20-40%", "+13%" + elif _vlen <= 12: + _vsize = 32 # "Baisse", "Conformité" + else: + _vsize = 22 # "Données fiables" + self._text(slide, x + 0.89, y + 2.41, col_w - 1.78, 3.68, + _val, + font=self.F_DISPLAY, size=_vsize, + bold=True, color=self.C["coral"], + anchor=MSO_ANCHOR.MIDDLE) + self._text(slide, x + 0.89, y + ch - 2.67, col_w - 1.78, 2.03, + pick(it, "description", "source", "detail"), + size=12, color=self.C["muted"]) + + def _render_key_message(self, slide, d): + self._bg(slide, self.C["navy"]) + self._text(slide, self.MX, 1.78, 5.6, 6.1, "\u201C", + font=self.F_DISPLAY, size=200, bold=True, + color=self.C["coral"]) + self._text(slide, 6.6, 5.84, 23.9, 4.32, + pick(d, "message", "texte", "citation"), + font=self.F_DISPLAY, size=32, bold=True, + color=self.C["white"], spacing=40) + detail = pick(d, "detail", "sous_message", "sous_texte", "soutien") + if detail: + self._text(slide, 6.6, 10.8, 21.6, 1.5, detail, + size=18, italic=True, color=self.C["glacier"]) + + def _render_circular_diagram(self, slide, d): + self._title(slide, pick(d, "titre", "title")) + segs = d.get("segments", []) + n = len(segs) + if not n: + return + D = 6.35 + cxc = 8.64 + cyc = self.CONT_Y + self.CONT_H / 2 + # positions : triangle pour 3, sinon cercle de positions + if n == 3: + centers = [(cxc - 1.83, cyc - 1.57), (cxc + 1.83, cyc - 1.57), + (cxc, cyc + 1.57)] + else: + import math + r_orb = D * 0.62 + centers = [(cxc + r_orb * math.cos(2 * math.pi * i / n - math.pi / 2), + cyc + r_orb * math.sin(2 * math.pi * i / n - math.pi / 2)) + for i in range(n)] + for i, seg in enumerate(segs): + col = seg.get("couleur") or self.cycle[i % len(self.cycle)] + cx0, cy0 = centers[i] + sh = self._oval(slide, cx0 - D / 2, cy0 - D / 2, D, col) + sh.line.color.rgb = hex_to_rgb(self.C["white"]) + sh.line.width = Pt(2) + for i, seg in enumerate(segs): + cx0, cy0 = centers[i] + below = cy0 > cyc # quadrant : numéro haut ou bas + ny = cy0 + (D / 2 - 1.83) * (1 if below else -1) - 0.7 + self._text(slide, cx0 - 1.78, ny, 3.56, 1.4, + str(i + 1).zfill(2), + font=self.F_DISPLAY, size=22, bold=True, + color=self.C["white"], align=PP_ALIGN.CENTER) + # légende droite — centrée verticalement + lh = 2.67 + leg_h = n * lh + ly = self._cy(leg_h) + for i, seg in enumerate(segs): + col = seg.get("couleur") or self.cycle[i % len(self.cycle)] + self._oval(slide, 17.8, ly + 0.2, 1.27, col) + self._text(slide, 19.7, ly, 12.7, 1.07, + f"{str(i + 1).zfill(2)} {seg.get('label', '')}", + size=17, bold=True, color=self.C["navy"]) + self._text(slide, 19.7, ly + 1.07, 12.7, 1.0, + seg.get("description", ""), + size=14, color=self.C["body"]) + ly += lh + + def _render_default_bullets(self, slide, d): + self._title(slide, pick(d, "titre", "title")) + bullets = d.get("bullets", []) + rh = 2.54 + tot = len(bullets) * rh + y = self._cy(tot) + mk = self.components["square_mark"]["size_cm"] + for b in bullets: + txt = b.get("texte", "") if isinstance(b, dict) else str(b) + self._rect(slide, self.MX + 0.25, y + (rh - 0.5) / 2 - mk / 2 + 0.25, + mk, mk, self.C["coral"]) + # "Mot : explication" → mot en gras navy + m = re.match(r"^([^:]{2,30})\s*:\s*(.+)$", txt) + if m: + parts = [(m.group(1) + " — ", + {"bold": True, "color": self.C["navy"]}), + (m.group(2), {"color": self.C["body"]})] + else: + parts = [(txt, {"color": self.C["body"]})] + self._rich(slide, self.MX + 1.52, y, + self.SLIDE_W - 2 * self.MX - 1.52, rh - 0.5, parts, + size=self.T["body_large"], anchor=MSO_ANCHOR.MIDDLE) + y += rh + + def _render_numbered_steps(self, slide, d): + self._title(slide, pick(d, "titre", "title")) + steps = d.get("steps", []) + rh, gap = 3.68, 0.76 + tot = len(steps) * rh + (len(steps) - 1) * gap + y = self._cy(tot) + bd = self.components["badge"]["sizes"]["l"] + for i, st in enumerate(steps): + self._card(slide, self.MX, y, self.SLIDE_W - 2 * self.MX, rh) + self._badge(slide, self.MX + 1.02 + bd / 2, y + rh / 2, bd, + st.get("numero", i + 1), font_size=28) + self._text(slide, self.MX + 4.32, y + 0.64, 14.2, 1.27, + pick(st, "titre", "title"), + size=19, bold=True, color=self.C["navy"]) + self._text(slide, self.MX + 4.32, y + 1.98, + self.SLIDE_W - 2 * self.MX - 5.84, 1.27, + pick(st, "description", "detail"), + size=15, color=self.C["body"]) + y += rh + gap + + def _render_phases_timeline(self, slide, d): + self._title(slide, pick(d, "titre", "title")) + phases = d.get("phases", []) + n = max(1, len(phases)) + gap = 1.02 + col_w = (self.SLIDE_W - 2 * self.MX - (n - 1) * gap) / n + bh = 3.81 + tot = bh + 2.41 + y = self._cy(tot) + # ligne pointillée de connexion + ln = slide.shapes.add_connector(1, Cm(self.MX + col_w / 2), + Cm(y + bh / 2), + Cm(self.SLIDE_W - self.MX - col_w / 2), + Cm(y + bh / 2)) + ln.line.color.rgb = hex_to_rgb(self.C["muted"]) + ln.line.width = Pt(1.25) + ln.line.dash_style = 4 # MSO_LINE.DASH + for i, ph in enumerate(phases): + col = self.cycle[i % len(self.cycle)] + x = self.MX + i * (col_w + gap) + sh = self._rect(slide, x, y, col_w, bh, col, rounded=True, + radius=0.08) + self._shadow(sh) + self._text(slide, x, y, col_w, bh, pick(ph, "label", "titre"), + font=self.F_DISPLAY, size=24, bold=True, + color=self.C["white"], align=PP_ALIGN.CENTER, + anchor=MSO_ANCHOR.MIDDLE) + self._text(slide, x, y + bh + 0.64, col_w, 1.5, + pick(ph, "periode", "description"), + size=15, color=self.C["body"], + align=PP_ALIGN.CENTER) + + def _render_recommendation_card(self, slide, d): + sbw = self.layouts["recommendation_card"].get("sidebar_width_cm", 9.9) + self._rect(slide, 0, 0, sbw, self.SLIDE_H, self.C["navy"]) + bd = self.components["badge"]["sizes"]["xl"] + titre = pick(d, "titre", "title") + cta = pick(d, "cta") + # contenu sidebar — centré verticalement sur la slide + cta_h = max(2.0, estimate_text_height(cta, 13, sbw - 2.3) + 0.8) if cta else 0 + inner = bd + 0.89 + 1.78 + (1.27 + cta_h if cta else 0) + sy = (self.SLIDE_H - inner) / 2 + self._badge(slide, sbw / 2, sy + bd / 2, bd, + d.get("numero", 1), fill=self.C["coral"], font_size=40) + self._text(slide, 0.76, sy + bd + 0.89, sbw - 1.52, 1.78, titre, + font=self.F_DISPLAY, size=24, bold=True, + color=self.C["white"], align=PP_ALIGN.CENTER) + if cta: + self._text(slide, 1.14, sy + bd + 0.89 + 1.78 + 1.27, + sbw - 2.28, cta_h, cta, + size=13, italic=True, color=self.C["glacier"], + align=PP_ALIGN.CENTER) + # corps droit + cx = sbw + 1.52 + cw = self.SLIDE_W - sbw - 3.04 + bullets = d.get("bullets", []) + texts = [b.get("texte", "") if isinstance(b, dict) else str(b) + for b in bullets] + # dédoublonnage + seen, dedup = set(), [] + for t in texts: + if t not in seen: + seen.add(t) + dedup.append(t) + hdr_h = 2.16 + body_h = len(dedup) * 1.57 + 1.78 + tot = hdr_h + body_h + y = (self.SLIDE_H - tot) / 2 + headline = pick(d, "headline", "header") + if headline: + self._rect(slide, cx, y, cw, hdr_h, self.C["navy"]) + self._text(slide, cx + 1.0, y, cw - 2.0, hdr_h, headline, + size=18, bold=True, color=self.C["white"], + anchor=MSO_ANCHOR.MIDDLE, char_spacing=2) + self._card(slide, cx, y + hdr_h, cw, body_h) + tb = slide.shapes.add_textbox(Cm(cx + 1.27), Cm(y + hdr_h + 0.89), + Cm(cw - 2.54), Cm(body_h - 1.78)) + tf = tb.text_frame + tf.word_wrap = True + tf.vertical_anchor = MSO_ANCHOR.MIDDLE + first = True + for t in dedup: + p = tf.paragraphs[0] if first else tf.add_paragraph() + first = False + p.space_after = Pt(12) + r = p.add_run() + r.text = "• " + t + r.font.name = self.F_BODY + r.font.size = Pt(16) + r.font.color.rgb = hex_to_rgb(self.C["body"]) + self._footer(slide, getattr(self, "_slide_num", "")) + + def _render_end_slide(self, slide, d): + self._bg(slide, self.C["navy"]) + for c in self.components["decor_circles"]["end"]: + col = {"theme.primary.navy_light": self.C["navy2"], + "theme.accent.coral": self.C["coral"]}[c["color"]] + self._oval(slide, c["x"], c["y"], c["d"], col) + self._text(slide, 4.06, 6.86, 25.7, 4.57, + pick(d, "message", "titre", "texte"), + font=self.F_DISPLAY, size=34, bold=True, + color=self.C["white"], align=PP_ALIGN.CENTER, + spacing=42) + self._text(slide, 4.06, 11.9, 25.7, 1.27, "Merci pour votre attention", + size=16, italic=True, color=self.C["glacier"], + align=PP_ALIGN.CENTER) + + + # ── NEW LAYOUTS — 8 extensions ──────────────────────────────────────────── + + def _col_positions(self, n_cols, first_col_w=None): + """Helper : retourne [(x, width), ...] pour chaque colonne.""" + if first_col_w and n_cols > 1: + rest = (self.SLIDE_W - 2*self.MX - first_col_w) / (n_cols - 1) + return [(self.MX, first_col_w)] + [ + (self.MX + first_col_w + i*rest, rest) for i in range(n_cols-1)] + col_w = (self.SLIDE_W - 2*self.MX) / max(1, n_cols) + return [(self.MX + i*col_w, col_w) for i in range(n_cols)] + + # ── from_to_pairs ───────────────────────────────────────────────────────── + def _render_from_to_pairs(self, slide, d): + self._title(slide, pick(d, "titre", "title")) + pairs = d.get("pairs", []) + if not pairs: + return + lbl_from = pick(d, "label_from", "ÉTAT ACTUEL") + lbl_to = pick(d, "label_to", "ÉTAT CIBLE") + ARROW_W = 2.54 + col_w = (self.SLIDE_W - 2*self.MX - ARROW_W) / 2 + row_h, gap, hdr_h = 1.52, 0.28, 0.76 + tot = hdr_h + gap + len(pairs) * (row_h + gap) + y = self._cy(tot) + ax = self.MX + col_w # arrow zone x + tx = ax + ARROW_W # TO column x + + # Headers + self._text(slide, self.MX, y, col_w, hdr_h, lbl_from, + size=13, bold=True, color=self.C["muted"], + char_spacing=3, anchor=MSO_ANCHOR.MIDDLE) + self._text(slide, tx, y, col_w, hdr_h, lbl_to, + size=13, bold=True, color=self.C["navy"], + char_spacing=3, anchor=MSO_ANCHOR.MIDDLE) + y += hdr_h + gap + + for pair in pairs: + # FROM card — blue tint + self._card(slide, self.MX, y, col_w, row_h, self.C["card_alt"]) + self._text(slide, self.MX + 0.64, y, col_w - 1.27, row_h, + pick(pair, "from", "de", "avant"), + size=15, italic=True, color=self.C["slate"], + anchor=MSO_ANCHOR.MIDDLE) + # Arrow + self._text(slide, ax, y, ARROW_W, row_h, "→", + size=30, bold=True, color=self.C["coral"], + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) + # TO card — warm + self._card(slide, tx, y, col_w, row_h) + self._text(slide, tx + 0.64, y, col_w - 1.27, row_h, + pick(pair, "to", "vers", "après"), + size=15, bold=True, color=self.C["navy"], + anchor=MSO_ANCHOR.MIDDLE) + y += row_h + gap + + # ── gantt_timeline ──────────────────────────────────────────────────────── + def _render_gantt_timeline(self, slide, d): + self._title(slide, pick(d, "titre", "title")) + periods = d.get("periods", []) + workstreams = d.get("workstreams", []) + if not periods or not workstreams: + return + n_p = len(periods) + LBL_W = 4.57 + GAP_COL = 0.25 + GRID_W = self.SLIDE_W - 2*self.MX - LBL_W - GAP_COL + col_w = GRID_W / n_p + HDR_H = 0.80 + WS_H = 0.68 + TASK_H = 0.78 + n_tasks = sum(len(ws.get("tasks", [])) for ws in workstreams) + total_h = HDR_H + len(workstreams)*WS_H + n_tasks*TASK_H + y0 = self._cy(total_h) + gx = self.MX + LBL_W + GAP_COL # grid x origin + + # Header + self._rect(slide, self.MX, y0, self.SLIDE_W - 2*self.MX, HDR_H, self.C["navy"]) + for i, p in enumerate(periods): + self._text(slide, gx + i*col_w, y0, col_w, HDR_H, p, + size=10, bold=True, color=self.C["white"], + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) + + # Subtle vertical separators over full grid height + for i in range(1, n_p): + xl = gx + i * col_w + ln = slide.shapes.add_connector(1, Cm(xl), Cm(y0 + HDR_H), + Cm(xl), Cm(y0 + total_h)) + ln.line.color.rgb = hex_to_rgb("#E0DEDB") + ln.line.width = Pt(0.5) + + cur_y = y0 + HDR_H + for ws_idx, ws in enumerate(workstreams): + ws_col = self.cycle[ws_idx % len(self.cycle)] + # Workstream row + self._rect(slide, self.MX, cur_y, self.SLIDE_W - 2*self.MX, WS_H, "#EEECEA") + self._rect(slide, self.MX, cur_y, 0.28, WS_H, ws_col) + self._text(slide, self.MX + 0.50, cur_y, LBL_W - 0.50, WS_H, + ws.get("label", ""), size=12, bold=True, + color=self.C["navy"], anchor=MSO_ANCHOR.MIDDLE) + cur_y += WS_H + for task in ws.get("tasks", []): + start = task.get("start", 0) + end = task.get("end", start + 1) + bg = self.C["white"] + self._rect(slide, self.MX, cur_y, self.SLIDE_W - 2*self.MX, TASK_H, bg) + self._text(slide, self.MX + 0.50, cur_y, LBL_W - 0.50, TASK_H, + " " + task.get("label", ""), size=11, + color=self.C["body"], anchor=MSO_ANCHOR.MIDDLE) + bx = gx + start * col_w + 0.14 + bw = (end - start) * col_w - 0.28 + self._rect(slide, bx, cur_y + 0.15, bw, TASK_H - 0.30, + ws_col, rounded=True) + cur_y += TASK_H + # bottom separator + ln = slide.shapes.add_connector(1, Cm(self.MX), Cm(cur_y), + Cm(self.SLIDE_W - self.MX), Cm(cur_y)) + ln.line.color.rgb = hex_to_rgb("#D8D4CF") + ln.line.width = Pt(0.5) + + # ── yearly_timeline ─────────────────────────────────────────────────────── + def _render_yearly_timeline(self, slide, d): + self._title(slide, pick(d, "titre", "title")) + milestones = d.get("milestones", []) + if not milestones: + return + n = len(milestones) + D = 1.27 # circle diameter + LBL_H = 1.52 + LINE_Y_DELTA = LBL_H + 0.38 # gap above line for top labels + tot_h = LBL_H * 2 + D + 0.76 + y_top = self._cy(tot_h) + line_y = y_top + LBL_H + 0.38 # centre of the line / circles + usable_w = self.SLIDE_W - 2*self.MX + step = usable_w / (n - 1) if n > 1 else usable_w + + # Horizontal line + ln = slide.shapes.add_connector(1, + Cm(self.MX), Cm(line_y + D/2), + Cm(self.SLIDE_W - self.MX), Cm(line_y + D/2)) + ln.line.color.rgb = hex_to_rgb(self.C["navy"]) + ln.line.width = Pt(1.75) + + for i, ms in enumerate(milestones): + cx = self.MX + i * step + active = ms.get("actif", False) + col = self.C["coral"] if active else self.C["navy"] + lbl_col = self.C["navy"] if active else self.C["body"] + lbl_sz = 13 if active else 12 + + # Circle + self._oval(slide, cx - D/2, line_y, D, col) + annee = str(pick(ms, "annee", "year", str(i+1))) + self._text(slide, cx - D/2, line_y, D, D, annee, + size=10, bold=True, color=self.C["white"], + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) + + # Label + lw = min(step * 0.85, 5.08) if n > 1 else usable_w + label = pick(ms, "label", "evenement", "event") + # Clamp : label ne sort jamais du slide + lx = max(self.MX, min(cx - lw/2, self.SLIDE_W - self.MX - lw)) + if i % 2 == 0: + self._text(slide, lx, y_top, lw, LBL_H, label, + size=lbl_sz, bold=active, color=lbl_col, + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.BOTTOM) + else: + self._text(slide, lx, line_y + D + 0.25, lw, LBL_H, label, + size=lbl_sz, bold=active, color=lbl_col, + align=PP_ALIGN.CENTER) + + # ── comparison_table ────────────────────────────────────────────────────── + def _render_comparison_table(self, slide, d): + self._title(slide, pick(d, "titre", "title")) + headers = d.get("headers", []) + rows = d.get("rows", []) + if not headers or not rows: + return + n_cols = len(headers) + n_rows = len(rows) + HDR_H = 1.02 + ROW_H = min(1.27, (self.CONT_H - HDR_H - 0.5) / n_rows) + FIRST_W = 5.08 + cols = self._col_positions(n_cols, FIRST_W if n_cols > 1 else None) + total_h = HDR_H + n_rows * ROW_H + y = self._cy(total_h) + + # Header + self._rect(slide, self.MX, y, self.SLIDE_W - 2*self.MX, HDR_H, self.C["navy"]) + for j, (x, w) in enumerate(cols): + self._text(slide, x + 0.33, y, w - 0.33, HDR_H, headers[j], + size=13, bold=True, color=self.C["white"], + anchor=MSO_ANCHOR.MIDDLE) + + # Rows + for i, row in enumerate(rows): + ry = y + HDR_H + i * ROW_H + bg = self.C["card"] if i % 2 == 0 else self.C["white"] + self._rect(slide, self.MX, ry, self.SLIDE_W - 2*self.MX, ROW_H, bg) + cells = list(row) if isinstance(row, (list, tuple)) else ([row.get("label", "")] + row.get("values", [])) if isinstance(row, dict) else [str(row)] + for j, (x, w) in enumerate(cols): + val = cells[j] if j < len(cells) else "" + self._text(slide, x + 0.33, ry, w - 0.33, ROW_H, str(val), + size=13, bold=(j == 0), color=self.C["navy"] if j == 0 else self.C["body"], + anchor=MSO_ANCHOR.MIDDLE) + # separator + ln = slide.shapes.add_connector(1, Cm(self.MX), Cm(ry + ROW_H), + Cm(self.SLIDE_W - self.MX), Cm(ry + ROW_H)) + ln.line.color.rgb = hex_to_rgb("#E8E4E0") + ln.line.width = Pt(0.5) + + # ── raci_table ──────────────────────────────────────────────────────────── + def _render_raci_table(self, slide, d): + self._title(slide, pick(d, "titre", "title")) + roles = d.get("roles", []) + tasks = d.get("tasks", []) + if not roles or not tasks: + return + RACI_COLORS = {"R": self.C["coral"], "A": self.C["navy"], + "C": self.C["slate"], "I": self.C["muted"]} + n_cols = len(roles) + 1 + n_rows = len(tasks) + HDR_H = 1.02 + ROW_H = min(1.14, (self.CONT_H - HDR_H - 0.5) / n_rows) + TASK_W = 6.35 + ROLE_W = (self.SLIDE_W - 2*self.MX - TASK_W) / len(roles) + BADGE = 0.64 + + total_h = HDR_H + n_rows * ROW_H + y = self._cy(total_h) + + # Header + self._rect(slide, self.MX, y, self.SLIDE_W - 2*self.MX, HDR_H, self.C["navy"]) + self._text(slide, self.MX + 0.33, y, TASK_W - 0.33, HDR_H, "Activité", + size=12, bold=True, color=self.C["white"], anchor=MSO_ANCHOR.MIDDLE) + for j, role in enumerate(roles): + rx = self.MX + TASK_W + j * ROLE_W + self._text(slide, rx, y, ROLE_W, HDR_H, role, + size=11, bold=True, color=self.C["white"], + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) + + # Rows + for i, task in enumerate(tasks): + ry = y + HDR_H + i * ROW_H + bg = self.C["card"] if i % 2 == 0 else self.C["white"] + self._rect(slide, self.MX, ry, self.SLIDE_W - 2*self.MX, ROW_H, bg) + self._text(slide, self.MX + 0.33, ry, TASK_W - 0.33, ROW_H, + task.get("label", ""), size=12, color=self.C["navy"], + anchor=MSO_ANCHOR.MIDDLE) + raci = task.get("raci", []) + for j, letter in enumerate(raci): + if j >= len(roles): + break + rx = self.MX + TASK_W + j * ROLE_W + (ROLE_W - BADGE) / 2 + by = ry + (ROW_H - BADGE) / 2 + col = RACI_COLORS.get(letter.upper(), self.C["muted"]) + self._rect(slide, rx, by, BADGE, BADGE, col, rounded=True, radius=0.08) + self._text(slide, rx, by, BADGE, BADGE, letter.upper(), + size=13, bold=True, color=self.C["white"], + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) + ln = slide.shapes.add_connector(1, Cm(self.MX), Cm(ry + ROW_H), + Cm(self.SLIDE_W - self.MX), Cm(ry + ROW_H)) + ln.line.color.rgb = hex_to_rgb("#E8E4E0") + ln.line.width = Pt(0.5) + + # ── process_arrow ───────────────────────────────────────────────────────── + def _render_process_arrow(self, slide, d): + self._title(slide, pick(d, "titre", "title")) + steps = d.get("steps", []) + if not steps: + return + n = len(steps) + ARR_W = 0.89 + BOX_H = 2.67 + DSC_H = 1.0 + usable = self.SLIDE_W - 2*self.MX - ARR_W * (n - 1) + box_w = usable / n + tot_h = BOX_H + DSC_H + 0.38 + y0 = self._cy(tot_h) + BADGE_D = 0.76 + + for i, step in enumerate(steps): + x = self.MX + i * (box_w + ARR_W) + col = self.cycle[i % len(self.cycle)] + # Box (dark bg = navy or cycle) + self._rect(slide, x, y0, box_w, BOX_H, col, rounded=True) + # Badge number top-center + self._oval(slide, x + (box_w - BADGE_D) / 2, y0 - BADGE_D/2, + BADGE_D, self.C["coral"]) + self._text(slide, x + (box_w - BADGE_D) / 2, y0 - BADGE_D/2, + BADGE_D, BADGE_D, str(i + 1), + size=14, bold=True, color=self.C["white"], + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) + # Title inside box + self._text(slide, x + 0.33, y0 + 0.38, box_w - 0.66, BOX_H - 0.76, + pick(step, "titre", "title", "label"), + size=15, bold=True, color=self.C["white"], + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) + # Description below + self._text(slide, x, y0 + BOX_H + 0.28, box_w, DSC_H, + pick(step, "description", "detail"), + size=12, color=self.C["body"], + align=PP_ALIGN.CENTER) + # Connecting arrow (except last) + if i < n - 1: + ax = x + box_w + self._text(slide, ax, y0, ARR_W, BOX_H, "›", + size=28, bold=True, color=self.C["coral"], + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) + + # ── org_chart ───────────────────────────────────────────────────────────── + def _render_org_chart(self, slide, d): + self._title(slide, pick(d, "titre", "title")) + root = d.get("root", {}) + if not root: + return + BOX_W, BOX_H = 3.81, 1.0 + GAP_V, GAP_H = 1.27, 0.64 + children = root.get("children", []) + n_children = len(children) + # grandchildren count per child (max) + max_gd = max((len(c.get("children", [])) for c in children), default=0) + + # Heights + levels = 1 + (1 if children else 0) + (1 if max_gd > 0 else 0) + tot_h = levels * BOX_H + (levels - 1) * GAP_V + cy_top = self._cy(tot_h) + + # Root box (centered, navy) + root_x = (self.SLIDE_W - BOX_W) / 2 + self._card(slide, root_x, cy_top, BOX_W, BOX_H, self.C["navy"]) + self._text(slide, root_x + 0.33, cy_top, BOX_W - 0.66, BOX_H, + root.get("label", ""), size=14, bold=True, + color=self.C["white"], align=PP_ALIGN.CENTER, + anchor=MSO_ANCHOR.MIDDLE) + + if not children: + return + + # Children row + child_y = cy_top + BOX_H + GAP_V + total_children_w = n_children * BOX_W + (n_children - 1) * GAP_H + child_x_start = (self.SLIDE_W - total_children_w) / 2 + + # Connector from root bottom to children row + for i, child in enumerate(children): + cx = child_x_start + i * (BOX_W + GAP_H) + col = self.C["card"] + self._card(slide, cx, child_y, BOX_W, BOX_H, col) + self._text(slide, cx + 0.33, child_y, BOX_W - 0.66, BOX_H, + child.get("label", ""), size=13, bold=True, + color=self.C["navy"], align=PP_ALIGN.CENTER, + anchor=MSO_ANCHOR.MIDDLE) + # Line: root bottom → child top + mid_root_x = root_x + BOX_W / 2 + mid_child_x = cx + BOX_W / 2 + mid_y = cy_top + BOX_H + GAP_V / 2 + # Vertical from root + ln = slide.shapes.add_connector(1, + Cm(mid_root_x), Cm(cy_top + BOX_H), + Cm(mid_root_x), Cm(mid_y)) + ln.line.color.rgb = hex_to_rgb(self.C["navy"]) + ln.line.width = Pt(1.0) + # Horizontal to child + ln2 = slide.shapes.add_connector(1, + Cm(mid_root_x if i == 0 else child_x_start + (n_children-1)*(BOX_W+GAP_H)/2 if i == n_children-1 else mid_child_x), + Cm(mid_y), Cm(mid_child_x), Cm(mid_y)) + ln2.line.color.rgb = hex_to_rgb(self.C["navy"]) + ln2.line.width = Pt(1.0) + # Vertical to child + ln3 = slide.shapes.add_connector(1, + Cm(mid_child_x), Cm(mid_y), + Cm(mid_child_x), Cm(child_y)) + ln3.line.color.rgb = hex_to_rgb(self.C["navy"]) + ln3.line.width = Pt(1.0) + + # Grandchildren — sizing dynamique pour tenir dans la colonne + grandchildren = child.get("children", []) + if grandchildren: + gd_y = child_y + BOX_H + GAP_V + n_gd = len(grandchildren) + col_span = BOX_W + GAP_H # largeur allouée par parent + GD_GAP = 0.25 + GD_W = min(BOX_W, (col_span - GD_GAP * (n_gd - 1)) / n_gd) + GD_W = max(1.78, GD_W) + gd_total = n_gd * GD_W + (n_gd - 1) * GD_GAP + gd_x_start = cx + BOX_W/2 - gd_total/2 + mid_cy_x = cx + BOX_W/2 + for k, gc in enumerate(grandchildren): + gx = gd_x_start + k * (GD_W + GD_GAP) + self._card(slide, gx, gd_y, GD_W, BOX_H, "#EAF0F8") + self._text(slide, gx + 0.20, gd_y, GD_W - 0.40, BOX_H, + gc.get("label", ""), size=10, + color=self.C["navy"], align=PP_ALIGN.CENTER, + anchor=MSO_ANCHOR.MIDDLE) + mid_gx = gx + GD_W/2 + ln4 = slide.shapes.add_connector(1, + Cm(mid_cy_x), Cm(child_y + BOX_H), + Cm(mid_gx), Cm(gd_y)) + ln4.line.color.rgb = hex_to_rgb(self.C["muted"]) + ln4.line.width = Pt(0.75) + + # ── matrix_2x2 ──────────────────────────────────────────────────────────── + def _render_matrix_2x2(self, slide, d): + self._title(slide, pick(d, "titre", "title")) + axis_x = d.get("axis_x", {}) + axis_y = d.get("axis_y", {}) + quadrants = d.get("quadrants", {}) + items = d.get("items", []) + ITEM_D = 0.89 + + # Matrix zone + AXIS_LBL = 1.27 + MAT_W = self.SLIDE_W - 2*self.MX - AXIS_LBL + MAT_H = self.CONT_H - AXIS_LBL - 0.3 + tot_h = MAT_H + AXIS_LBL + y0 = self._cy(tot_h) + mx0 = self.MX + AXIS_LBL # matrix x start + + # Quadrant backgrounds + hw, hh = MAT_W/2, MAT_H/2 + Q_FILLS = ["#EAF0F8", self.C["card"], "#E8F4EC", "#FFF0EC"] + Q_POS = [(mx0, y0), (mx0+hw, y0), (mx0, y0+hh), (mx0+hw, y0+hh)] + for fill, (qx, qy) in zip(Q_FILLS, Q_POS): + self._rect(slide, qx, qy, hw, hh, fill) + + # Axis lines + for lx, ly, ex, ey in [ + (mx0, y0, mx0 + MAT_W, y0), + (mx0, y0 + MAT_H, mx0 + MAT_W, y0 + MAT_H), + (mx0, y0, mx0, y0 + MAT_H), + (mx0 + MAT_W, y0, mx0 + MAT_W, y0 + MAT_H), + (mx0 + hw, y0, mx0 + hw, y0 + MAT_H), # vertical mid + (mx0, y0 + hh, mx0 + MAT_W, y0 + hh), # horizontal mid + ]: + ln = slide.shapes.add_connector(1, Cm(lx), Cm(ly), Cm(ex), Cm(ey)) + ln.line.color.rgb = hex_to_rgb("#C8C4BE" if "mid" not in str(lx) else "#C8C4BE") + ln.line.width = Pt(1.0 if (lx == mx0+hw or ly == y0+hh) else 1.5) + + # Quadrant labels (corners) + QD = { + "top_left": (mx0 + 0.3, y0 + 0.2), + "top_right": (mx0 + hw + 0.3, y0 + 0.2), + "bottom_left": (mx0 + 0.3, y0 + hh + 0.2), + "bottom_right": (mx0 + hw + 0.3, y0 + hh + 0.2), + } + for key, (qx, qy) in QD.items(): + label = quadrants.get(key, "") + if label: + self._text(slide, qx, qy, hw - 0.5, 0.64, label, + size=11, italic=True, color=self.C["muted"]) + + # Axis labels + ax_lbl = pick(axis_x, "label", "x") + ay_lbl = pick(axis_y, "label", "y") + if ax_lbl: + self._text(slide, mx0, y0 + MAT_H + 0.12, MAT_W, AXIS_LBL, ax_lbl, + size=13, bold=True, color=self.C["navy"], + align=PP_ALIGN.CENTER) + if ay_lbl: + # Axe Y : flèche verticale + label court à gauche de la matrice + ln_ay = slide.shapes.add_connector(1, + Cm(self.MX + AXIS_LBL * 0.5), Cm(y0 + MAT_H), + Cm(self.MX + AXIS_LBL * 0.5), Cm(y0)) + ln_ay.line.color.rgb = hex_to_rgb(self.C["navy"]) + ln_ay.line.width = Pt(1.5) + self._text(slide, self.MX, y0 - 0.76, AXIS_LBL, 0.64, + f"↑ {ay_lbl}", + size=12, bold=True, color=self.C["navy"]) + + # Axis extremities + for lbl, pos, anchor in [ + (pick(axis_x, "low", "low_x", ""), (mx0, y0 + MAT_H + 0.1), PP_ALIGN.LEFT), + (pick(axis_x, "high", "high_x", ""), (mx0 + MAT_W - 1.5, y0 + MAT_H + 0.1), PP_ALIGN.RIGHT), + (pick(axis_y, "high", "high_y", ""), (self.MX, y0 + 0.1), PP_ALIGN.CENTER), + (pick(axis_y, "low", "low_y", ""), (self.MX, y0 + MAT_H - 0.8), PP_ALIGN.CENTER), + ]: + if lbl: + self._text(slide, pos[0], pos[1], 1.5, 0.5, lbl, + size=10, italic=True, color=self.C["muted"], align=anchor) + + # Items as numbered circles + for i, item in enumerate(items): + ix = item.get("x", 50) # 0-100 + iy = item.get("y", 50) # 0-100 + px = mx0 + (ix / 100) * MAT_W + py = y0 + ((100 - iy) / 100) * MAT_H + self._oval(slide, px - ITEM_D/2, py - ITEM_D/2, ITEM_D, self.C["coral"]) + self._text(slide, px - ITEM_D/2, py - ITEM_D/2, ITEM_D, ITEM_D, + str(i + 1), size=12, bold=True, color=self.C["white"], + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) + + # Legend below x-axis label + if items: + leg_y = y0 + MAT_H + AXIS_LBL * 0.55 + leg_x = mx0 + for i, item in enumerate(items): + lx = leg_x + i * 3.56 + if lx + 3.3 > self.SLIDE_W - self.MX: + break + self._oval(slide, lx, leg_y - 0.3, 0.5, self.C["coral"]) + self._text(slide, lx, leg_y - 0.3, 0.5, 0.5, str(i+1), + size=9, bold=True, color=self.C["white"], + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) + self._text(slide, lx + 0.6, leg_y - 0.35, 2.8, 0.6, + item.get("label", ""), size=10, color=self.C["body"]) + + + # ---------------- orchestration ---------------- + REGISTRY = { + "cover_split": "_render_cover_split", + "executive_summary": "_render_executive_summary", + "section_divider": "_render_section_divider", + "big_stat": "_render_big_stat", + "two_cols_text": "_render_two_cols_text", + "kpi_grid": "_render_kpi_grid", + "key_message": "_render_key_message", + "circular_diagram": "_render_circular_diagram", + "default_bullets": "_render_default_bullets", + "numbered_steps": "_render_numbered_steps", + "phases_timeline": "_render_phases_timeline", + "recommendation_card": "_render_recommendation_card", + "end_slide": "_render_end_slide", + "from_to_pairs": "_render_from_to_pairs", + "gantt_timeline": "_render_gantt_timeline", + "yearly_timeline": "_render_yearly_timeline", + "comparison_table": "_render_comparison_table", + "raci_table": "_render_raci_table", + "process_arrow": "_render_process_arrow", + "org_chart": "_render_org_chart", + "matrix_2x2": "_render_matrix_2x2", + } + + def render(self, data, output_path: str): + if isinstance(data, str): + data = yaml.safe_load(data) + slides = data.get("slides", data) if isinstance(data, dict) else data + + prs = Presentation() + prs.slide_width = Cm(self.SLIDE_W) + prs.slide_height = Cm(self.SLIDE_H) + blank = prs.slide_layouts[6] + self._section_counter = 0 + excluded = set(self.theme["signature"]["footer_excluded_layouts"]) + + for i, sd in enumerate(slides): + slide = prs.slides.add_slide(blank) + layout = sd.get("layout", "default_bullets") + self._slide_num = i + 1 + method = self.REGISTRY.get(layout) + if not method: + print(f" ⚠ Layout inconnu '{layout}' → default_bullets") + method = "_render_default_bullets" + layout = "default_bullets" + mode = self.layouts.get(layout, {}).get("mode", "light") + if mode == "light": + self._bg(slide, self.C["white"]) + getattr(self, method)(slide, sd) + if layout not in excluded and layout != "recommendation_card": + self._footer(slide, i + 1) + + prs.save(output_path) + print(f"✓ PPTX généré : {output_path} ({len(slides)} slides)") + + +def main(): + import argparse + parser = argparse.ArgumentParser( + description="Sliding render_engine V2 — PR Editorial") + parser.add_argument("input_file", help="Fichier YAML/JSON de la présentation") + parser.add_argument("output", help="Chemin du PPTX à générer") + parser.add_argument("--theme", default="theme_v2.yaml") + parser.add_argument("--components", default="components_v2.yaml") + parser.add_argument("--layouts", default="layouts_v2.yaml") + args = parser.parse_args() + + for f in [args.input_file, args.theme, args.components, args.layouts]: + if not os.path.exists(f): + print(f"✗ Fichier introuvable : {f}") + sys.exit(1) + + with open(args.input_file, encoding="utf-8") as f: + data = yaml.safe_load(f) + + engine = RenderEngineV2(args.theme, args.layouts, args.components) + engine.render(data, args.output) + + +if __name__ == "__main__": + main() diff --git a/theme_v2.yaml b/theme_v2.yaml new file mode 100644 index 0000000..7a093b2 --- /dev/null +++ b/theme_v2.yaml @@ -0,0 +1,93 @@ +# ============================================================ +# THEME V2 — Design System "PR Editorial" +# Pernod Ricard / SlidingAutomation +# ============================================================ +# Principes : +# 1. Navy dominant (60-70%), corail = accent unique +# 2. Sandwich dark/light (cover + dividers + quote + end en navy) +# 3. Pas de barre d'accent ni stripe décoratif +# 4. Motif récurrent : le cercle +# 5. Échelle typographique stricte (4 niveaux) +# ============================================================ + +meta: + name: "PR Editorial" + version: "2.0" + date: "2026-06-12" + +colors: + primary: + navy: "#061033" # dominant — fonds dark, titres, badges + navy_light: "#10204D" # cercles décoratifs sur fond navy, numéro ghost + accent: + coral: "#F4795B" # accent UNIQUE — stats, pastilles, flèches + secondary: + glacier: "#8FA9D0" # 3e couleur de cycle, sous-titres sur dark + slate: "#46505A" # 2e couleur de cycle + backgrounds: + white: "#FFFFFF" # fond des slides de contenu + card: "#F4F1EC" # fond des cartes (gris chaud, PAS beige/lin) + card_alt: "#EAF0F8" # variante froide (colonnes "solutions") + text: + body: "#2B3440" + muted: "#8A93A0" + on_dark: "#FFFFFF" + cycle: # pour séries (KPI, phases, cercles) + - "#061033" + - "#46505A" + - "#8FA9D0" + - "#F4795B" + +fonts: + display: "Cambria" # titres, stats, numéros (serif safe-font) + body: "Calibri" # corps de texte (sans safe-font) + +# Échelle typographique (pt) — 4 niveaux, pas plus +typography: + slide_title: 28 # titre de slide (insight) + section_title: 34 # titre des section dividers + cover_title: 44 + header: 18 # en-têtes de cartes / colonnes + body: 16 # corps minimum (accessibilité) + body_large: 19 # bullets pleine slide + caption: 12 # sources, footer + stat_card: 54 # chiffre dans carte KPI + stat_hero: 120 # big_stat plein écran + badge: 28 # numéro dans badge rond + ghost_number: 250 # numéro géant des dividers + +# Grille fixe — TOUTES les valeurs en cm (slide 33.87 x 19.05) +grid: + margin_x: 1.78 # marge latérale + title_y: 1.14 + title_h: 2.41 + content_y: 4.32 # début zone contenu + content_b: 17.40 # fin zone contenu + footer_y: 17.91 + gap: 0.80 # espacement standard entre blocs + gap_small: 0.55 + +# Style des cartes (composant unifié) +card_style: + fill: "#F4F1EC" + corner_radius: 0.06 # adjustment ratio rounded rectangle + shadow: + blur_pt: 8 + dist_pt: 2 + dir_deg: 90 + color: "#061033" + alpha_pct: 12 + +signature: + accent_bar: false # SUPPRIMÉ — marqueur de slide IA + footer: + left: "{num}" + right: "Pernod Ricard — Data Governance" + size: 9 + color: "#8A93A0" + # le footer n'apparaît PAS sur les slides dark (cover, divider, quote, end) + footer_excluded_layouts: + - cover_split + - section_divider + - key_message + - end_slide