diff --git a/archive/facilitator_v1.0.py b/archive/facilitator_v1.0.py deleted file mode 100644 index 34be07f..0000000 --- a/archive/facilitator_v1.0.py +++ /dev/null @@ -1,613 +0,0 @@ -#!/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 deleted file mode 100644 index d2fb8df..0000000 --- a/archive/render_engine_v1.0.py +++ /dev/null @@ -1,1915 +0,0 @@ -""" -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 deleted file mode 100644 index fe6b931..0000000 --- a/archive/render_engine_v1.1.py +++ /dev/null @@ -1,1924 +0,0 @@ -""" -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 deleted file mode 100644 index fe6b931..0000000 --- a/archive/render_engine_v1.11.py +++ /dev/null @@ -1,1924 +0,0 @@ -""" -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 deleted file mode 100644 index 6865ab2..0000000 --- a/archive/v1_pipeline/agent_constraints.md +++ /dev/null @@ -1,740 +0,0 @@ -# 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 deleted file mode 100644 index 45c89f2..0000000 --- a/archive/v1_pipeline/components.yaml +++ /dev/null @@ -1,1266 +0,0 @@ -# ============================================================================= -# 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 deleted file mode 100644 index 6867d01..0000000 --- a/archive/v1_pipeline/facilitator.py +++ /dev/null @@ -1,620 +0,0 @@ -#!/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 deleted file mode 100644 index a599aae..0000000 --- a/archive/v1_pipeline/layouts.yaml +++ /dev/null @@ -1,1743 +0,0 @@ -# ============================================================================= -# 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 deleted file mode 100644 index 9e244f7..0000000 --- a/archive/v1_pipeline/prompt_injection.py +++ /dev/null @@ -1,626 +0,0 @@ -""" -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 deleted file mode 100644 index c6a0429..0000000 --- a/archive/v1_pipeline/prompt_the_designer.md +++ /dev/null @@ -1,194 +0,0 @@ -# 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 deleted file mode 100644 index ceabf79..0000000 --- a/archive/v1_pipeline/prompt_the_designer_injected.md +++ /dev/null @@ -1,374 +0,0 @@ -# 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 deleted file mode 100644 index 3ebceb7..0000000 --- a/archive/v1_pipeline/prompt_the_encoder.md +++ /dev/null @@ -1,136 +0,0 @@ -# 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 deleted file mode 100644 index 535bd6d..0000000 --- a/archive/v1_pipeline/prompt_the_encoder_injected.md +++ /dev/null @@ -1,673 +0,0 @@ -# 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 deleted file mode 100644 index 9375334..0000000 --- a/archive/v1_pipeline/prompt_the_narrator.md +++ /dev/null @@ -1,167 +0,0 @@ -# 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 deleted file mode 100644 index 70b4a37..0000000 --- a/archive/v1_pipeline/render_engine.py +++ /dev/null @@ -1,2655 +0,0 @@ -""" -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 deleted file mode 100644 index d9775b5..0000000 --- a/archive/v1_pipeline/render_engine.py.bak2 +++ /dev/null @@ -1,1955 +0,0 @@ -""" -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 deleted file mode 100644 index 565259f..0000000 --- a/archive/v1_pipeline/theme.yaml +++ /dev/null @@ -1,329 +0,0 @@ -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