""" 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()